Просмотр исходного кода

Merge pull request #1843 from 5151561/debug-tools

 修复一批阅读器 / 主题相关问题
Kudomaga 3 месяцев назад
Родитель
Сommit
ea98676a02
46 измененных файлов с 943 добавлено и 67 удалено
  1. 3 0
      .gitignore
  2. 17 0
      app/src/debug/AndroidManifest.xml
  3. 28 0
      app/src/debug/assets/debug-fixtures/long-chapter-v1.json
  4. 35 0
      app/src/debug/assets/debug-fixtures/paragraph-review-v1.html
  5. 26 0
      app/src/debug/java/io/legado/app/debug/DebugParagraphReviewFixture.kt
  6. 253 0
      app/src/debug/java/io/legado/app/debug/DebugScenarioActivity.kt
  7. 59 0
      app/src/debug/java/io/legado/app/debug/DebugStateProvider.kt
  8. 66 0
      app/src/debug/java/io/legado/app/debug/DebugThemeAssetProvider.kt
  9. 26 1
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  10. 0 7
      app/src/main/java/io/legado/app/data/repository/AppStartupRepository.kt
  11. 2 2
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  12. 0 1
      app/src/main/java/io/legado/app/domain/gateway/AppStartupGateway.kt
  13. 0 4
      app/src/main/java/io/legado/app/domain/usecase/AppStartupMaintenanceUseCase.kt
  14. 9 3
      app/src/main/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCase.kt
  15. 7 1
      app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt
  16. 57 0
      app/src/main/java/io/legado/app/ui/book/read/EyeProtection.kt
  17. 1 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  18. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  19. 10 1
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  20. 8 0
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt
  21. 20 1
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt
  22. 11 1
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt
  23. 11 3
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  24. 0 1
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  25. 0 4
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  26. 1 0
      app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt
  27. 9 1
      app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt
  28. 20 0
      app/src/main/java/io/legado/app/ui/theme/Typography.kt
  29. 8 1
      app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt
  30. 29 2
      app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
  31. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapMode.kt
  32. 16 2
      app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
  33. 11 2
      app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassTopAppBar.kt
  34. 54 1
      app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt
  35. 7 1
      app/src/main/java/io/legado/app/utils/FileDocExtensions.kt
  36. 1 1
      app/src/main/java/io/legado/app/utils/RegexExtensions.kt
  37. 14 17
      app/src/main/java/io/legado/app/utils/WebSettingsExtensions.kt
  38. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  39. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  40. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  41. 1 0
      app/src/main/res/values/strings.xml
  42. 2 1
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  43. 25 0
      app/src/test/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCaseTest.kt
  44. 15 1
      app/src/test/java/io/legado/app/help/config/ThemePackageManagerRollbackTest.kt
  45. 53 0
      app/src/test/java/io/legado/app/ui/book/read/EyeProtectionTest.kt
  46. 23 0
      app/src/test/java/io/legado/app/ui/book/readRecord/ReadRecordFormatterTest.kt

+ 3 - 0
.gitignore

@@ -18,3 +18,6 @@ node_modules/
 .kotlin/
 /.vscode/settings.json
 /.codex/skills
+/.debug-runs/
+__pycache__/
+*.py[cod]

+ 17 - 0
app/src/debug/AndroidManifest.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application>
+        <activity
+            android:name="io.legado.app.debug.DebugScenarioActivity"
+            android:exported="true"
+            android:theme="@style/AppTheme.Transparent" />
+        <provider
+            android:name="io.legado.app.debug.DebugThemeAssetProvider"
+            android:authorities="${applicationId}.debug-theme-assets"
+            android:exported="true" />
+        <provider
+            android:name="io.legado.app.debug.DebugStateProvider"
+            android:authorities="${applicationId}.debug-state"
+            android:exported="true" />
+    </application>
+</manifest>

+ 28 - 0
app/src/debug/assets/debug-fixtures/long-chapter-v1.json

@@ -0,0 +1,28 @@
+{
+  "schemaVersion": 1,
+  "id": "long-chapter-v1",
+  "resetPolicy": "replace",
+  "book": {
+    "bookUrl": "legado-debug://fixture/long-chapter-v1",
+    "name": "调试夹具:长章节",
+    "author": "Legado Debug",
+    "pageMode": "simulation",
+    "startChapter": 0,
+    "startPosition": 0
+  },
+  "chapters": [
+    {
+      "title": "第一章 稳定翻页",
+      "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+      "paragraph": "这是用于阅读页翻页调试的固定长章节。每一段都带有稳定编号,用于确认页面内容确实发生变化。天地玄黄,宇宙洪荒;日月盈昃,辰宿列张。",
+      "repeat": 180,
+      "paragraphReviewFixture": "paragraph-review-v1"
+    },
+    {
+      "title": "第二章 边界验证",
+      "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1_CHAPTER_2",
+      "paragraph": "这是第二个固定章节,用于连续翻页越过章节边界后的行为验证。内容完全离线,不依赖书源、网络或设备历史状态。",
+      "repeat": 80
+    }
+  ]
+}

+ 35 - 0
app/src/debug/assets/debug-fixtures/paragraph-review-v1.html

@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <style>
+    :root { color-scheme: light dark; font-family: sans-serif; }
+    body { margin: 0; padding: 18px; background: Canvas; color: CanvasText; }
+    h2 { margin: 0 0 6px; font-size: 20px; }
+    .quote { margin: 0 0 16px; padding: 10px 12px; border-left: 4px solid #4f6bed; opacity: .78; }
+    .review { display: grid; grid-template-columns: 38px 1fr; gap: 10px; padding: 14px 0; border-top: 1px solid color-mix(in srgb, CanvasText 18%, transparent); }
+    .avatar { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; color: white; font-weight: 700; background: #4f6bed; }
+    .name { font-weight: 700; }
+    .time { margin-left: 8px; opacity: .55; font-size: 12px; }
+    .content { margin: 6px 0 0; line-height: 1.55; }
+    .reply { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, CanvasText 8%, transparent); font-size: 14px; }
+  </style>
+</head>
+<body>
+  <h2>段评 · 3 条</h2>
+  <p id="LEGADO_DEBUG_PARAGRAPH_REVIEW_V1" class="quote">第一段:这是用于阅读页翻页调试的固定长章节……</p>
+  <section class="review">
+    <div class="avatar">长</div>
+    <div><span class="name">长章节读者</span><span class="time">刚刚</span><p class="content">这是一条由 Debug Tool 注入的假段评,用来观察日夜模式切换。</p></div>
+  </section>
+  <section class="review">
+    <div class="avatar" style="background:#b35c1e">测</div>
+    <div><span class="name">测试账号</span><span class="time">2 分钟前</span><p class="content">段评属于正文第一段,不属于章节标题。</p><div class="reply">回复:点击入口后应打开真实的半屏 WebView。</div></div>
+  </section>
+  <section class="review">
+    <div class="avatar" style="background:#23856d">阅</div>
+    <div><span class="name">阅读观察员</span><span class="time">5 分钟前</span><p class="content">切换日夜模式时,这个列表应当立即跟随主题变化。</p></div>
+  </section>
+</body>
+</html>

+ 26 - 0
app/src/debug/java/io/legado/app/debug/DebugParagraphReviewFixture.kt

@@ -0,0 +1,26 @@
+package io.legado.app.debug
+
+import android.content.res.AssetManager
+import android.util.Base64
+import org.json.JSONObject
+
+internal object DebugParagraphReviewFixture {
+
+    fun createTag(assets: AssetManager, fixtureId: String): String {
+        require(fixtureId == FIXTURE_ID) { "unsupported paragraph review fixture" }
+        val html = assets.open("debug-fixtures/$fixtureId.html").use { it.readBytes() }
+        val htmlBase64 = Base64.encodeToString(html, Base64.NO_WRAP)
+        val iconUrl = "data:image/svg+xml;base64," + Base64.encodeToString(
+            ICON.toByteArray(),
+            Base64.NO_WRAP,
+        )
+        val click = "$MARKER java.showBrowser('$URL', java.base64Decode('$htmlBase64'))"
+        val imageOptions = JSONObject(mapOf("style" to "text", "click" to click)).toString()
+        return "<img src=\"$iconUrl,$imageOptions\">"
+    }
+
+    private const val FIXTURE_ID = "paragraph-review-v1"
+    private const val URL = "https://legado-debug.local/paragraph-reviews"
+    const val MARKER = "/*LEGADO_DEBUG_PARAGRAPH_REVIEW_V1*/"
+    private const val ICON = """<svg xmlns="http://www.w3.org/2000/svg" width="48" height="32" viewBox="0 0 48 32"><path fill="#4f6bed" d="M4 2h40a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H20l-8 5v-5H4a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4z"/><text x="24" y="21" text-anchor="middle" font-family="sans-serif" font-size="16" font-weight="700" fill="white">3</text></svg>"""
+}

+ 253 - 0
app/src/debug/java/io/legado/app/debug/DebugScenarioActivity.kt

@@ -0,0 +1,253 @@
+package io.legado.app.debug
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import androidx.room.withTransaction
+import io.legado.app.constant.BookType
+import io.legado.app.constant.PageAnim
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookSource
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.ui.book.source.manage.BookSourceActivity
+import io.legado.app.ui.config.ConfigTag
+import io.legado.app.ui.main.MainIntent
+import io.legado.app.utils.GSON
+import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class DebugScenarioActivity : AppCompatActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val fixtureId = intent.getStringExtra(EXTRA_FIXTURE)
+        val sessionId = intent.getStringExtra(EXTRA_SESSION).orEmpty()
+        val entry = intent.getStringExtra(EXTRA_ENTRY) ?: ENTRY_READER
+        val preferencesJson = intent.getStringExtra(EXTRA_PREFERENCES_B64)?.let {
+            String(android.util.Base64.decode(it, android.util.Base64.DEFAULT), Charsets.UTF_8)
+        }
+        if (fixtureId == null || !FIXTURE_ID.matches(fixtureId)) {
+            fail(sessionId, "invalid fixture ID")
+            return
+        }
+        if (entry !in SUPPORTED_ENTRIES) {
+            fail(sessionId, "unsupported entry: $entry")
+            return
+        }
+
+        lifecycleScope.launch {
+            runCatching {
+                val bookUrl = withContext(Dispatchers.IO) { installFixture(fixtureId) }
+                applyPreferences(preferencesJson, sessionId)
+                bookUrl
+            }.onSuccess { bookUrl ->
+                Log.i(LOG_TAG, "[session=$sessionId] FIXTURE_READY fixture=$fixtureId entry=$entry")
+                val targetIntent = when (entry) {
+                    ENTRY_BOOKSHELF -> {
+                        AppConfigStore.putString(PreferKey.defaultHomePage, ENTRY_BOOKSHELF)
+                        AppConfigStore.putLong(PreferKey.saveTabPosition, BookGroup.IdAll)
+                        MainIntent.createHomeIntent(this@DebugScenarioActivity)
+                    }
+                    ENTRY_SOURCE_MANAGE -> Intent(
+                        this@DebugScenarioActivity,
+                        BookSourceActivity::class.java,
+                    )
+                    ENTRY_THEME_CONFIG -> MainIntent.createIntent(
+                        this@DebugScenarioActivity,
+                        ConfigTag.THEME_CONFIG,
+                    )
+                    ENTRY_RSS_READ -> MainIntent.createRssReadIntent(
+                        context = this@DebugScenarioActivity,
+                        title = "DEBUG RSS 日夜切换",
+                        origin = DEBUG_ORIGIN,
+                        openUrl = DEBUG_RSS_HTML,
+                    )
+                    else -> MainIntent.createReadBookIntent(this@DebugScenarioActivity, bookUrl)
+                }
+                startActivity(
+                    targetIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+                )
+            }.onFailure { error ->
+                fail(sessionId, "fixture import failed", error)
+            }
+        }
+    }
+
+    private suspend fun installFixture(fixtureId: String): String {
+        val fixture = assets.open("debug-fixtures/$fixtureId.json").bufferedReader().use {
+            GSON.fromJson(it, DebugFixture::class.java)
+        }
+        require(fixture.schemaVersion == 1) { "unsupported fixture schema" }
+        require(fixture.id == fixtureId) { "fixture ID mismatch" }
+        require(fixture.resetPolicy == "replace") { "unsupported reset policy" }
+        require(fixture.chapters.isNotEmpty()) { "fixture must contain chapters" }
+        require(fixture.book.startChapter in fixture.chapters.indices) { "invalid start chapter" }
+
+        val pageAnim = when (fixture.book.pageMode) {
+            "simulation" -> PageAnim.simulationPageAnim
+            else -> error("unsupported page mode: ${fixture.book.pageMode}")
+        }
+        val chapters = fixture.chapters.mapIndexed { index, chapter ->
+            require(chapter.repeat in 1..1000) { "invalid repeat for chapter $index" }
+            BookChapter(
+                url = "${fixture.book.bookUrl}/chapter/$index",
+                title = chapter.title,
+                baseUrl = fixture.book.bookUrl,
+                bookUrl = fixture.book.bookUrl,
+                index = index,
+            )
+        }
+        val book = Book(
+            bookUrl = fixture.book.bookUrl,
+            tocUrl = "${fixture.book.bookUrl}/toc",
+            origin = DEBUG_ORIGIN,
+            originName = "Legado Debug Fixture",
+            name = fixture.book.name,
+            author = fixture.book.author,
+            type = BookType.text,
+            totalChapterNum = chapters.size,
+            durChapterTitle = chapters[fixture.book.startChapter].title,
+            durChapterIndex = fixture.book.startChapter,
+            durChapterPos = fixture.book.startPosition,
+            durChapterTime = System.currentTimeMillis(),
+            canUpdate = false,
+            readConfig = Book.ReadConfig(pageAnim = pageAnim),
+        )
+
+        appDb.withTransaction {
+            appDb.bookSourceDao.insert(
+                BookSource(
+                    bookSourceUrl = DEBUG_ORIGIN,
+                    bookSourceName = "Legado Debug Fixture",
+                    enabled = false,
+                    enabledExplore = false,
+                )
+            )
+            appDb.bookDao.getBook(book.bookUrl)?.let { appDb.bookDao.delete(it) }
+            appDb.bookDao.insert(book)
+            appDb.bookChapterDao.insert(*chapters.toTypedArray())
+        }
+        chapters.forEachIndexed { index, chapter ->
+            val definition = fixture.chapters[index]
+            val paragraphReview = definition.paragraphReviewFixture?.let {
+                DebugParagraphReviewFixture.createTag(assets, it)
+            }
+            if (paragraphReview != null) {
+                AppConfigStore.putAll(
+                    mapOf(
+                        PreferKey.enableReview to false,
+                        PreferKey.clickImgWay to "0",
+                    )
+                )
+            }
+            val content = buildString {
+                if (paragraphReview == null) {
+                    appendLine(definition.readyMarker)
+                    repeat(definition.repeat) { paragraphIndex ->
+                        append(paragraphIndex + 1)
+                        append(". ")
+                        appendLine(definition.paragraph)
+                    }
+                } else {
+                    append(definition.readyMarker)
+                    append(" 1. ")
+                    append(definition.paragraph)
+                    appendLine(paragraphReview)
+                    repeat(definition.repeat - 1) { paragraphIndex ->
+                        append(paragraphIndex + 2)
+                        append(". ")
+                        appendLine(definition.paragraph)
+                    }
+                }
+            }
+            BookHelp.saveText(book, chapter, content)
+        }
+        return book.bookUrl
+    }
+
+    /**
+     * Apply scenario-declared settings before launching the target UI so bug
+     * preconditions (theme engine, reader toggles, ...) are reproduced by
+     * parameter instead of manual UI toggling. Only String/Boolean values are
+     * accepted; they land in the AppConfigStore overlay and are read
+     * synchronously by the relaunched Activity in this process.
+     */
+    private fun applyPreferences(json: String?, sessionId: String) {
+        if (json.isNullOrBlank()) return
+        val type = object : TypeToken<Map<String, Any?>>() {}.type
+        val preferences: Map<String, Any?> = GSON.fromJson(json, type) ?: emptyMap()
+        val applied = HashMap<String, Any>()
+        preferences.forEach { (key, value) ->
+            when (value) {
+                is String, is Boolean -> applied[key] = value
+                else -> error("unsupported preference type for $key")
+            }
+        }
+        if (applied.isNotEmpty()) {
+            AppConfigStore.putAll(applied)
+            Log.i(LOG_TAG, "[session=$sessionId] PREFERENCES_APPLIED ${applied.keys}")
+        }
+    }
+
+    private fun fail(sessionId: String, message: String, error: Throwable? = null) {
+        Log.e(LOG_TAG, "[session=$sessionId] FIXTURE_ERROR $message", error)
+        finishAffinity()
+    }
+
+    private data class DebugFixture(
+        val schemaVersion: Int,
+        val id: String,
+        val resetPolicy: String,
+        val book: DebugBook,
+        val chapters: List<DebugChapter>,
+    )
+
+    private data class DebugBook(
+        val bookUrl: String,
+        val name: String,
+        val author: String,
+        val pageMode: String,
+        val startChapter: Int,
+        val startPosition: Int,
+    )
+
+    private data class DebugChapter(
+        val title: String,
+        val readyMarker: String,
+        val paragraph: String,
+        val repeat: Int,
+        val paragraphReviewFixture: String? = null,
+    )
+
+    private companion object {
+        const val EXTRA_FIXTURE = "fixture"
+        const val EXTRA_SESSION = "session"
+        const val EXTRA_ENTRY = "entry"
+        const val EXTRA_PREFERENCES_B64 = "preferencesB64"
+        const val LOG_TAG = "LegadoDebug"
+        const val DEBUG_ORIGIN = "legado-debug://fixture-source"
+        const val ENTRY_READER = "reader"
+        const val ENTRY_BOOKSHELF = "bookshelf"
+        const val ENTRY_SOURCE_MANAGE = "source_manage"
+        const val ENTRY_THEME_CONFIG = "theme_config"
+        const val ENTRY_RSS_READ = "rss_read"
+        const val DEBUG_RSS_HTML = "data:text/html,<html><body style='background:white;color:black'><h1>DEBUG RSS THEME</h1><p>DAY NIGHT WEBVIEW</p></body></html>"
+        val SUPPORTED_ENTRIES = setOf(
+            ENTRY_READER,
+            ENTRY_BOOKSHELF,
+            ENTRY_SOURCE_MANAGE,
+            ENTRY_THEME_CONFIG,
+            ENTRY_RSS_READ,
+        )
+        val FIXTURE_ID = Regex("[a-z0-9][a-z0-9-]*")
+    }
+}

+ 59 - 0
app/src/debug/java/io/legado/app/debug/DebugStateProvider.kt

@@ -0,0 +1,59 @@
+package io.legado.app.debug
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import android.util.Base64
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import kotlinx.coroutines.runBlocking
+import org.json.JSONObject
+import org.koin.core.context.GlobalContext
+
+class DebugStateProvider : ContentProvider() {
+
+    override fun onCreate(): Boolean = true
+
+    override fun call(method: String, arg: String?, extras: Bundle?): Bundle = Bundle().apply {
+        val json = when (method) {
+            METHOD_TOGGLE_READER_DAY_NIGHT -> toggleReaderDayNight()
+            else -> error("unsupported method: $method")
+        }
+        putString(
+            RESULT_BASE64,
+            Base64.encodeToString(json.toString().toByteArray(), Base64.NO_WRAP),
+        )
+    }
+
+    private fun toggleReaderDayNight(): JSONObject = runBlocking {
+        val gateway = GlobalContext.get().get<AppShellSettingsGateway>()
+        val current = gateway.currentSettings.themeMode
+        gateway.update { it.copy(themeMode = if (current == "2") "1" else "2") }
+        JSONObject().put("themeMode", gateway.currentSettings.themeMode)
+    }
+
+    override fun query(
+        uri: Uri,
+        projection: Array<out String>?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+        sortOrder: String?,
+    ): Cursor? = null
+
+    override fun getType(uri: Uri): String? = null
+    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
+    override fun update(
+        uri: Uri,
+        values: ContentValues?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+    ): Int = 0
+
+    private companion object {
+        const val METHOD_TOGGLE_READER_DAY_NIGHT = "toggleReaderDayNight"
+        const val RESULT_BASE64 = "resultB64"
+    }
+
+}

+ 66 - 0
app/src/debug/java/io/legado/app/debug/DebugThemeAssetProvider.kt

@@ -0,0 +1,66 @@
+package io.legado.app.debug
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import java.io.File
+import java.io.FileNotFoundException
+
+class DebugThemeAssetProvider : ContentProvider() {
+
+    override fun onCreate(): Boolean = true
+
+    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
+        if (failureArmed) {
+            val error = FileNotFoundException("Injected theme asset failure: $uri")
+            Log.e(LOG_TAG, "INJECTED_THEME_ASSET_OPEN uri=$uri mode=$mode", error)
+            throw error
+        }
+        val context = requireNotNull(context)
+        val fixture = File(context.cacheDir, "debug-theme-provider-background.png")
+        if (!fixture.exists()) {
+            context.assets.open("web/uploadBook/img/close.png").use { input ->
+                fixture.outputStream().use(input::copyTo)
+            }
+        }
+        return ParcelFileDescriptor.open(fixture, ParcelFileDescriptor.MODE_READ_ONLY)
+    }
+
+    override fun call(method: String, arg: String?, extras: android.os.Bundle?): android.os.Bundle? {
+        if (method == "armFailure") {
+            failureArmed = true
+            Log.i(LOG_TAG, "INJECTED_THEME_ASSET_FAILURE_ARMED")
+        }
+        return null
+    }
+
+    override fun getType(uri: Uri): String = "image/png"
+
+    override fun query(
+        uri: Uri,
+        projection: Array<out String>?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+        sortOrder: String?,
+    ): Cursor? = null
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
+
+    override fun update(
+        uri: Uri,
+        values: ContentValues?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+    ): Int = 0
+
+    private companion object {
+        const val LOG_TAG = "LegadoDebug"
+        @Volatile
+        var failureArmed = false
+    }
+}

+ 26 - 1
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -6,9 +6,12 @@ import android.os.Bundle
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.toArgb
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.os.LocaleListCompat
@@ -22,9 +25,12 @@ import io.legado.app.constant.EventBus
 import io.legado.app.constant.Theme
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.AppUiConfiguration
 import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.ui.book.read.eyeProtectionColorFilter
+import io.legado.app.ui.book.read.rememberEyeProtectionActive
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
@@ -48,6 +54,7 @@ abstract class BaseComposeActivity(
 
     private val appLocaleGateway by inject<AppLocaleGateway>()
     private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private val themeSettingsGateway by inject<ThemeSettingsGateway>()
     private var lastUiConfiguration: AppUiConfiguration? = null
 
     @Composable
@@ -67,12 +74,30 @@ abstract class BaseComposeActivity(
         setupSystemBar()
         // Compose 入口
         val initialUiConfiguration = appUiConfigurationGateway.currentConfiguration
+        val initialThemeSettings = themeSettingsGateway.currentSettings
         setContent {
             val uiConfiguration by appUiConfigurationGateway.configuration
                 .collectAsStateWithLifecycle(initialUiConfiguration)
             AppTheme(configuration = uiConfiguration) {
                 SyncWindowBackground()
-                Content()
+                val themeSettings by themeSettingsGateway.settings
+                    .collectAsStateWithLifecycle(initialThemeSettings)
+                val eyeProtectionActive = rememberEyeProtectionActive(
+                    enabled = themeSettings.eyeProtectionEnabled,
+                    schedule = themeSettings.eyeProtectionSchedule,
+                    startTime = themeSettings.eyeProtectionStartTime,
+                    endTime = themeSettings.eyeProtectionEndTime,
+                )
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        .eyeProtectionColorFilter(
+                            enabled = eyeProtectionActive,
+                            intensity = themeSettings.colorTemperature,
+                        )
+                ) {
+                    Content()
+                }
             }
         }
 

+ 0 - 7
app/src/main/java/io/legado/app/data/repository/AppStartupRepository.kt

@@ -2,7 +2,6 @@ package io.legado.app.data.repository
 
 import io.legado.app.data.AppDatabase
 import io.legado.app.domain.gateway.AppStartupGateway
-import io.legado.app.help.DefaultData
 
 class AppStartupRepository(
     private val appDatabase: AppDatabase
@@ -11,10 +10,4 @@ class AppStartupRepository(
     override suspend fun deleteNotShelfBooks() {
         appDatabase.bookDao.deleteNotShelfBook()
     }
-
-    override suspend fun ensureDefaultHttpTts() {
-        if (appDatabase.httpTTSDao.count == 0) {
-            appDatabase.httpTTSDao.insert(*DefaultData.httpTTS.toTypedArray())
-        }
-    }
 }

+ 2 - 2
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -345,7 +345,7 @@ class ReadSettingsRepository(
             adaptSpecialStyle = compatDsValue(Keys.AdaptSpecialStyle, true),
             useZhLayout = compatDsValue(Keys.UseZhLayout, false),
             eyeProtectionEnabled = compatDsValue(Keys.EyeProtectionEnabled, false),
-            eyeProtectionIntensity = compatDsValue(Keys.EyeProtectionIntensity, 50),
+            eyeProtectionIntensity = compatDsValue(Keys.EyeProtectionIntensity, 50).coerceIn(0, 100),
             eyeProtectionAutoNight = compatDsValue(Keys.EyeProtectionAutoNight, false),
             showBrightnessView = compatDsValue(Keys.ShowBrightnessView, "0"),
             brightnessVwPos = compatDsValue(Keys.BrightnessVwPos, "1"),
@@ -577,7 +577,7 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity,
+    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
     PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,

+ 0 - 1
app/src/main/java/io/legado/app/domain/gateway/AppStartupGateway.kt

@@ -2,5 +2,4 @@ package io.legado.app.domain.gateway
 
 interface AppStartupGateway {
     suspend fun deleteNotShelfBooks()
-    suspend fun ensureDefaultHttpTts()
 }

+ 0 - 4
app/src/main/java/io/legado/app/domain/usecase/AppStartupMaintenanceUseCase.kt

@@ -9,8 +9,4 @@ class AppStartupMaintenanceUseCase(
     suspend fun deleteNotShelfBooks() {
         appStartupGateway.deleteNotShelfBooks()
     }
-
-    suspend fun ensureDefaultHttpTts() {
-        appStartupGateway.ensureDefaultHttpTts()
-    }
 }

+ 9 - 3
app/src/main/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCase.kt

@@ -22,6 +22,7 @@ class SyncReadAloudVoicesUseCase(
     suspend operator fun invoke(
         entries: List<VoiceCatalogEntry>,
         managedSources: Set<String>,
+        removeMissingEngineTypes: Set<String> = emptySet(),
         now: Long = System.currentTimeMillis(),
     ): SyncReadAloudVoicesResult = syncMutex.withLock {
         val existing = voiceGateway.getVoices()
@@ -79,10 +80,15 @@ class SyncReadAloudVoicesUseCase(
         var unavailable = 0
         existing.asSequence()
             .filter { it.managedBy in managedSources }
-            .filter { it.id !in incomingIds && it.available }
+            .filter { it.id !in incomingIds }
             .forEach { voice ->
-                unavailable++
-                voiceGateway.upsertVoice(voice.copy(available = false, updatedAt = now))
+                if (voice.engineType in removeMissingEngineTypes) {
+                    unavailable++
+                    voiceGateway.deleteVoice(voice)
+                } else if (voice.available) {
+                    unavailable++
+                    voiceGateway.upsertVoice(voice.copy(available = false, updatedAt = now))
+                }
             }
         SyncReadAloudVoicesResult(inserted, updated, unavailable)
     }

+ 7 - 1
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -380,7 +380,12 @@ class ThemePackageManager(
         root: File,
         manifest: ThemePackageManifest,
     ) {
-        val localAssets = resolveSavedAssets(root, manifest.assets)
+        val copiedAssets = mutableListOf<File>()
+        val localAssets = importAssets(
+            root = root,
+            entries = manifest.assets,
+            copiedFiles = copiedAssets,
+        )
         val savedAlbumId = theme.data.selectedCoverAlbumId
             ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
         var importedAlbumIds = emptyList<String>()
@@ -424,6 +429,7 @@ class ThemePackageManager(
                     runCatching { coverAlbumUseCase.deleteAlbum(id) }
                 }
             }
+            copiedAssets.forEach(File::delete)
             throw error
         }
     }

+ 57 - 0
app/src/main/java/io/legado/app/ui/book/read/EyeProtection.kt

@@ -1,11 +1,17 @@
 package io.legado.app.ui.book.read
 
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.ColorMatrix as ComposeColorMatrix
+import kotlinx.coroutines.delay
+import java.time.LocalTime
 
 
 object EyeProtection {
@@ -46,6 +52,35 @@ object EyeProtection {
     fun syncEnabledForNight(isNight: Boolean, autoNight: Boolean): Boolean? =
         if (autoNight) isNight else null
 
+    /**
+     * 计算护眼在给定时刻是否应生效。
+     * - 总开关关闭 → 永远不生效;
+     * - 未启用定时 → 只要总开关开就生效;
+     * - 启用定时但时间无法解析 → 退化为仅按总开关生效;
+     * - 启用定时 → 仅在 [startTime, endTime) 时段内生效(支持跨零点,如 22:00–07:00)。
+     */
+    fun isActiveAt(
+        enabled: Boolean,
+        schedule: Boolean,
+        startTime: String,
+        endTime: String,
+        now: LocalTime,
+    ): Boolean {
+        if (!enabled) return false
+        if (!schedule) return true
+        val start = parseTime(startTime) ?: return true
+        val end = parseTime(endTime) ?: return true
+        if (start == end) return true
+        return if (start < end) {
+            now >= start && now < end
+        } else {
+            now >= start || now < end
+        }
+    }
+
+    private fun parseTime(value: String): LocalTime? =
+        runCatching { LocalTime.parse(value.trim()) }.getOrNull()
+
     private val IDENTITY_MATRIX = floatArrayOf(
         1f, 0f, 0f, 0f, 0f,
         0f, 1f, 0f, 0f, 0f,
@@ -74,3 +109,25 @@ fun Modifier.eyeProtectionColorFilter(
         colorFilter = cachedColorFilter
     }
 }
+
+/**
+ * 解析护眼当前是否生效,启用定时时按分钟自动重算,使时段边界能实时切换。
+ */
+@Composable
+fun rememberEyeProtectionActive(
+    enabled: Boolean,
+    schedule: Boolean,
+    startTime: String,
+    endTime: String,
+): Boolean {
+    if (!enabled) return false
+    if (!schedule) return true
+    var now by remember { mutableStateOf(LocalTime.now()) }
+    LaunchedEffect(startTime, endTime) {
+        while (true) {
+            now = LocalTime.now()
+            delay(60_000L)
+        }
+    }
+    return EyeProtection.isActiveAt(enabled, schedule, startTime, endTime, now)
+}

+ 1 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -523,12 +523,7 @@ fun ReadBookRouteScreen(
     var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
 
     Box(
-        Modifier
-            .fillMaxSize()
-            .eyeProtectionColorFilter(
-                enabled = readPreferences.eyeProtectionEnabled,
-                intensity = readPreferences.eyeProtectionIntensity,
-            )
+        Modifier.fillMaxSize()
     ) {
         key(controller) {
             ReadBookViewLayer(

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1744,6 +1744,7 @@ class ReadBookViewModel(
                 }
             },
             managedSources = setOf(ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS),
+            removeMissingEngineTypes = setOf(ReadAloudVoice.ENGINE_HTTP),
         )
     }
 

+ 10 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -6,12 +6,14 @@ import android.graphics.RectF
 import android.os.Build
 import android.os.Bundle
 import android.util.AttributeSet
+import android.util.Log
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
+import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.data.entities.BookProgress
@@ -632,7 +634,14 @@ class ReadView(
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
-            contentDescription = pageFactory.curPage.text
+            val text = pageFactory.curPage.text
+            contentDescription = text
+            if (BuildConfig.DEBUG) {
+                // Device-independent readiness/page-change signal for the debug
+                // scenario runner (uiautomator does not expose ReadView on all
+                // devices). See tools/android/runner.py.
+                Log.i("LegadoDebug", "READER_PAGE " + text.replace("\n", " "))
+            }
         }
         if (isScroll && !isAutoPage) {
             if (relativePosition == 0) {

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt

@@ -1,8 +1,11 @@
 package io.legado.app.ui.book.readRecord
 
 import io.legado.app.utils.formatReadDuration
+import kotlin.time.Duration.Companion.milliseconds
 
 object ReadRecordFormatter {
+    data class HourMinuteDuration(val hours: Long, val minutes: Int)
+
     fun formatWords(words: Long): String {
         return if (words >= 10000) {
             String.format("%.1f万字", words / 10000f)
@@ -12,4 +15,9 @@ object ReadRecordFormatter {
     }
 
     fun formatDuration(millis: Long): String = formatReadDuration(millis)
+
+    fun hourMinuteDuration(millis: Long): HourMinuteDuration =
+        millis.milliseconds.toComponents { hours, minutes, _, _ ->
+            HourMinuteDuration(hours, minutes)
+        }
 }

+ 20 - 1
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -143,8 +143,27 @@ fun ReadRecordOverviewScreen(
                 }
 
                 item {
+                    val readingTime = ReadRecordFormatter.hourMinuteDuration(state.totalTime)
+                    val readingTimeText = when {
+                        readingTime.hours > 0 && readingTime.minutes > 0 -> stringResource(
+                            R.string.hours_minutes_format,
+                            readingTime.hours,
+                            readingTime.minutes,
+                        )
+                        readingTime.hours > 0 -> stringResource(
+                            R.string.whole_hours_format,
+                            readingTime.hours,
+                        )
+                        else -> stringResource(
+                            R.string.minutes_format,
+                            readingTime.minutes,
+                        )
+                    }
                     val stats = listOf(
-                        StatItem(stringResource(R.string.reading_time), ReadRecordFormatter.formatDuration(state.totalTime)),
+                        StatItem(
+                            stringResource(R.string.reading_time),
+                            readingTimeText,
+                        ),
                         StatItem(stringResource(R.string.reading_days), stringResource(R.string.days_format, state.readingDays)),
                         StatItem(stringResource(R.string.total_read_books), stringResource(R.string.books_format, state.totalBooks)),
                         StatItem(stringResource(R.string.finished_books), stringResource(R.string.books_format, state.finishedBooks)),

+ 11 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt

@@ -96,7 +96,17 @@ class CloudTtsViewModel(
                         ReadAloudVoice.ENGINE_HTTP,
                     )
                 })
-            }.collect { (allEngines, allHttpEngines, savedVoices) ->
+            }.collect { (allEngines, allHttpEngines, allSavedVoices) ->
+                val configuredHttpEngineIds = allHttpEngines.mapTo(mutableSetOf()) { it.sourceId }
+                val orphanedVoiceIds = allSavedVoices.asSequence()
+                    .filter { it.engineType == ReadAloudVoice.ENGINE_HTTP }
+                    .filter { it.managedBy == ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS }
+                    .filter { it.engineId !in configuredHttpEngineIds }
+                    .mapTo(mutableSetOf()) { it.id }
+                allSavedVoices
+                    .filter { it.id in orphanedVoiceIds }
+                    .forEach { voiceGateway.deleteVoice(it) }
+                val savedVoices = allSavedVoices.filterNot { it.id in orphanedVoiceIds }
                 engines = allEngines
                 httpEngines = allHttpEngines
                 voices = savedVoices

+ 11 - 3
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -20,6 +20,7 @@ import io.legado.app.utils.inputStream
 import io.legado.app.utils.openInputStream
 import java.io.File
 import java.io.FileOutputStream
+import java.util.UUID
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -374,17 +375,24 @@ class ThemeConfigViewModel(
                     .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
                     ?: "ttf"
                 val fontDir = File(appCtx.filesDir, "fonts").apply { mkdirs() }
-                val target = File(fontDir, "app_font.$extension")
-                val temp = File(fontDir, "app_font.$extension.tmp")
+                val target = File(fontDir, "app_font_${UUID.randomUUID()}.$extension")
+                val temp = File(fontDir, "${target.name}.tmp")
                 fileDoc.openInputStream().getOrThrow().use { input ->
                     FileOutputStream(temp).use(input::copyTo)
                 }
-                if (target.exists() && !target.delete()) error("Unable to replace app font")
                 if (!temp.renameTo(target)) {
                     temp.copyTo(target, overwrite = true)
                     temp.delete()
                 }
+                val oldPath = themeSettingsGateway.currentSettings.appFontPath
                 themeSettingsGateway.update { it.copy(appFontPath = target.absolutePath) }
+                oldPath?.let(::File)
+                    ?.takeIf {
+                        it != target &&
+                            it.parentFile == fontDir &&
+                            it.name.startsWith("app_font")
+                    }
+                    ?.delete()
             }.onFailure(Throwable::printStackTrace)
         }
     }

+ 0 - 1
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -235,7 +235,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
             if (otherSettingsGateway.currentSettings.autoRefresh && !isAutoRefreshedBook) {
                 viewModel.upAllBookToc()
             }
-            viewModel.postLoad()
             if (shouldAutoCheckUpdate) {
                 checkUpdateOnStart()
             }

+ 0 - 4
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -59,10 +59,6 @@ class MainViewModel(
         FlowEventBus.post(EventBus.UP_ALL_BOOK_TOC, Unit)
     }
 
-    fun postLoad() {
-        viewModelScope.launch { appStartupMaintenanceUseCase.ensureDefaultHttpTts() }
-    }
-
     fun restoreWebDav(name: String) {
         viewModelScope.launch { webDavBackupUseCase.restore(name) }
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -204,6 +204,7 @@ fun RssReadRouteScreen(
     LaunchedEffect(isNight, webView) {
         val currentWebView = webView ?: return@LaunchedEffect
         currentWebView.settings.setDarkeningAllowed(isNight)
+        currentWebView.postInvalidate()
     }
 
     Box(modifier = Modifier.fillMaxSize()) {

+ 9 - 1
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
 import top.yukonga.miuix.kmp.theme.ColorSchemeMode
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.ThemeController
+import top.yukonga.miuix.kmp.theme.defaultTextStyles
 
 @Composable
 fun rememberCustomFont(fontPath: String?): FontFamily? {
@@ -124,7 +125,14 @@ fun MiuixThemeWrapper(
         }
     }
 
-    MiuixTheme(controller = controller) {
+    val miuixTextStyles = remember(customFontFamily) {
+        defaultTextStyles().withFont(customFontFamily)
+    }
+
+    MiuixTheme(
+        controller = controller,
+        textStyles = miuixTextStyles,
+    ) {
         val miuixStyles = MiuixTheme.textStyles
         val legadoTypography = remember(miuixStyles, customFontFamily) {
             miuixStylesToM3Typography(miuixStyles)

+ 20 - 0
app/src/main/java/io/legado/app/ui/theme/Typography.kt

@@ -37,6 +37,26 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography {
     )
 }
 
+fun TextStyles.withFont(fontFamily: FontFamily?): TextStyles {
+    if (fontFamily == null) return this
+    return copy(
+        main = main.copy(fontFamily = fontFamily),
+        paragraph = paragraph.copy(fontFamily = fontFamily),
+        body1 = body1.copy(fontFamily = fontFamily),
+        body2 = body2.copy(fontFamily = fontFamily),
+        button = button.copy(fontFamily = fontFamily),
+        footnote1 = footnote1.copy(fontFamily = fontFamily),
+        footnote2 = footnote2.copy(fontFamily = fontFamily),
+        headline1 = headline1.copy(fontFamily = fontFamily),
+        headline2 = headline2.copy(fontFamily = fontFamily),
+        subtitle = subtitle.copy(fontFamily = fontFamily),
+        title1 = title1.copy(fontFamily = fontFamily),
+        title2 = title2.copy(fontFamily = fontFamily),
+        title3 = title3.copy(fontFamily = fontFamily),
+        title4 = title4.copy(fontFamily = fontFamily),
+    )
+}
+
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 fun Typography.toLegadoTypography(): LegadoTypography {
     return LegadoTypography(

+ 8 - 1
app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt

@@ -20,6 +20,8 @@ import io.legado.app.R
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.utils.FileDoc
+import io.legado.app.utils.isContentScheme
+import java.io.File
 
 @Composable
 fun FontSelectSheet(
@@ -41,7 +43,12 @@ fun FontSelectSheet(
     val selectedFontName = remember(selectedFontPath) {
         selectedFontPath?.let {
             runCatching {
-                DocumentFile.fromSingleUri(context, it.toUri())?.name
+                val uri = it.toUri()
+                if (uri.isContentScheme()) {
+                    DocumentFile.fromSingleUri(context, uri)?.name
+                } else {
+                    File(uri.path ?: it).name
+                }
             }.getOrNull()
         }
     }

+ 29 - 2
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -1,6 +1,8 @@
 package io.legado.app.ui.widget.components
 
 import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
@@ -14,6 +16,7 @@ import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalAppUiConfiguration
@@ -43,7 +46,25 @@ fun SplicedColumnGroup(
     val composeEngine = LegadoTheme.composeEngine
     val themeSettings = LocalAppUiConfiguration.current.theme
     val enableItemDivider = themeSettings.enableItemDivider
-    val cornerRadius = if (themeSettings.disableSplicedColumnGroupCornerRadius) 0.dp else 16.dp
+    val cornerRadius = when {
+        themeSettings.disableSplicedColumnGroupCornerRadius -> 0.dp
+        themeSettings.overrideBaseCardCornerRadius -> themeSettings.baseCardCornerRadius.dp
+        else -> 16.dp
+    }
+    val border = if (themeSettings.overrideBaseCardBorder) {
+        val configuredColor = if (LegadoTheme.isDark) {
+            themeSettings.baseCardBorderColorNight
+        } else {
+            themeSettings.baseCardBorderColor
+        }
+        BorderStroke(
+            themeSettings.baseCardBorderWidth.dp,
+            configuredColor.takeIf { it != 0 }?.let(::Color)
+                ?: LegadoTheme.colorScheme.outlineVariant
+        )
+    } else {
+        null
+    }
     val currentIndex = remember { mutableIntStateOf(0) }
 
     val groupState = remember(enableItemDivider) {
@@ -64,7 +85,13 @@ fun SplicedColumnGroup(
 
         CompositionLocalProvider(LocalSplicedColumnGroupState provides groupState) {
             if (ThemeResolver.isMiuixEngine(composeEngine)) {
-                MiuixCard(cornerRadius = cornerRadius) {
+                MiuixCard(
+                    modifier = Modifier.then(
+                        border?.let { Modifier.border(it, RoundedCornerShape(cornerRadius)) }
+                            ?: Modifier
+                    ),
+                    cornerRadius = cornerRadius,
+                ) {
                     Column(
                         modifier = Modifier
                             .fillMaxWidth()

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapMode.kt

@@ -9,7 +9,7 @@ enum class HeatmapMode {
 
 data class HeatmapConfig(
     val cellSize: Dp = 16.dp,
-    val touchTargetSize: Dp = 48.dp,
+    val touchTargetSize: Dp = 16.dp,
     val cellSpacing: Dp = 4.dp,
     val cornerRadius: Dp = 4.dp,
     val gradientWidth: Dp = 16.dp,

+ 16 - 2
app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt

@@ -6,8 +6,12 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
 import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.MaterialTheme
@@ -34,7 +38,11 @@ import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@OptIn(
+    ExperimentalMaterial3Api::class,
+    ExperimentalMaterial3ExpressiveApi::class,
+    ExperimentalLayoutApi::class,
+)
 @Composable
 fun GlassMediumFlexibleTopAppBar(
     title: String,
@@ -90,13 +98,19 @@ fun GlassMediumFlexibleTopAppBar(
     ) {
         when {
             isMiuix -> {
+                // Reserve constant status-bar space (ignoring visibility) instead
+                // of MiuixTopAppBar's default animating status-bar padding. This
+                // matches Material3's TopAppBar (systemBarsForVisualComponents) so
+                // the bar/content doesn't reflow down when the status bar is
+                // re-shown — e.g. returning from a reader that hid it.
                 MiuixTopAppBar(
-                    modifier = Modifier,
+                    modifier = Modifier.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility),
                     title = title,
                     subtitle = subtitleText.orEmpty(),
                     navigationIcon = navigationIcon,
                     actions = actions,
                     color = Color.Transparent,
+                    defaultWindowInsetsPadding = false,
                     scrollBehavior = (scrollBehavior as? MiuixGlassScrollBehavior)?.miuixBehavior
                 )
             }

+ 11 - 2
app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassTopAppBar.kt

@@ -2,7 +2,11 @@ package io.legado.app.ui.widget.components.topbar
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
 import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
@@ -18,7 +22,7 @@ import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.responsiveHazeEffect
 import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
 @Composable
 fun GlassTopAppBar(
     title: String,
@@ -46,11 +50,16 @@ fun GlassTopAppBar(
 
     Column(modifier = finalModifier) {
         if (isMiuix) {
+            // Reserve constant status-bar space (ignoring visibility) like
+            // Material3's TopAppBar, so content doesn't reflow when the status
+            // bar is re-shown (e.g. returning from a reader that hid it).
             MiuixTopAppBar(
+                modifier = Modifier.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility),
                 title = title,
                 navigationIcon = navigationIcon,
                 actions = actions,
-                color = Color.Transparent
+                color = Color.Transparent,
+                defaultWindowInsetsPadding = false
             )
         } else {
             TopAppBar(

+ 54 - 1
app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.widget.dialog
 import android.annotation.SuppressLint
 import android.content.Context
 import android.content.pm.ActivityInfo
+import android.content.res.Configuration
 import android.graphics.Bitmap
 import android.net.Uri
 import android.net.http.SslError
@@ -31,7 +32,9 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.core.net.toUri
 import androidx.core.view.size
 import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import io.legado.app.R
@@ -41,6 +44,7 @@ import io.legado.app.constant.AppLog
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.databinding.DialogWebViewBinding
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.WebCacheManager
 import io.legado.app.help.config.AppConfig
@@ -68,6 +72,7 @@ import io.legado.app.utils.invisible
 import io.legado.app.utils.longSnackbar
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.setLayout
+import io.legado.app.utils.setDarkeningAllowed
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -75,11 +80,14 @@ import io.legado.app.utils.visible
 import io.legado.app.utils.writeBytes
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import java.io.ByteArrayInputStream
 import java.lang.ref.WeakReference
 import java.util.Date
+import org.koin.android.ext.android.inject
 
 class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view),
     WebJsExtensions.Callback {
@@ -116,6 +124,8 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
         }
     }
     private val displayMetrics by lazy { resources.displayMetrics }
+    private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private var appliedDarkTheme: Boolean? = null
     private val selectImageDir = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { uri ->
             ACache.get().put(imagePathKey, uri.toString())
@@ -373,6 +383,7 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
         super.onViewCreated(view, savedInstanceState)
         view.setBackgroundColor(0)
         binding.webViewContainer.addView(currentWebView)
+        observeAppTheme()
         lifecycleScope.launch(IO) {
             val args = arguments
             if (args == null) {
@@ -502,6 +513,43 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
         currentWebView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
     }
 
+    private fun observeAppTheme() {
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                appUiConfigurationGateway.configuration
+                    .map { it.isDarkTheme }
+                    .distinctUntilChanged()
+                    .collect(::applyWebViewTheme)
+            }
+        }
+    }
+
+    private fun applyWebViewTheme(isDark: Boolean, force: Boolean = false) {
+        if (!force && appliedDarkTheme == isDark) return
+        appliedDarkTheme = isDark
+        val configuration = Configuration(currentWebView.resources.configuration).apply {
+            uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (isDark) {
+                Configuration.UI_MODE_NIGHT_YES
+            } else {
+                Configuration.UI_MODE_NIGHT_NO
+            }
+        }
+        currentWebView.dispatchConfigurationChanged(configuration)
+        currentWebView.settings.setDarkeningAllowed(isDark)
+        currentWebView.evaluateJavascript(
+            """
+            (() => {
+              const root = document.documentElement;
+              if (!root) return;
+              root.style.setProperty('color-scheme', '${if (isDark) "dark" else "light"}', 'important');
+              window.dispatchEvent(new CustomEvent('legado-theme-change', { detail: { dark: $isDark } }));
+            })();
+            """.trimIndent(),
+            null,
+        )
+        currentWebView.postInvalidate()
+    }
+
     private fun buildSpliceHtml(originalHtml: String): String {
         if (preloadJs.isNullOrEmpty()) return originalHtml
         val headIndex = originalHtml.indexOf("<head", ignoreCase = true)
@@ -736,6 +784,11 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
             }
         }
 
+        override fun onPageFinished(view: WebView?, url: String?) {
+            super.onPageFinished(view, url)
+            appliedDarkTheme?.let { applyWebViewTheme(it, force = true) }
+        }
+
         private fun shouldOverrideUrlLoading(url: Uri): Boolean {
             return when (url.scheme) {
                 "http", "https" -> {
@@ -816,4 +869,4 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
             }
         }
     }
-}
+}

+ 7 - 1
app/src/main/java/io/legado/app/utils/FileDocExtensions.kt

@@ -365,7 +365,13 @@ fun FileDoc.checkWrite(): Boolean {
  * DocumentFile 的 listFiles() 非常的慢,尽量不要使用
  */
 fun DocumentFile.listFileDocs(filter: FileDocFilter? = null): ArrayList<FileDoc>? {
-    return FileDoc.fromDocumentFile(this).list(filter)
+    return FileDoc(
+        name = "",
+        isDir = true,
+        size = 0,
+        lastModified = 0,
+        uri = uri,
+    ).list(filter)
 }
 
 @Throws(Exception::class)

+ 1 - 1
app/src/main/java/io/legado/app/utils/RegexExtensions.kt

@@ -83,4 +83,4 @@ fun CharSequence.replace(
             }
         }
     }
-}
+}

+ 14 - 17
app/src/main/java/io/legado/app/utils/WebSettingsExtensions.kt

@@ -5,7 +5,6 @@ import android.os.Build
 import android.webkit.WebSettings
 import androidx.webkit.WebSettingsCompat
 import androidx.webkit.WebViewFeature
-import io.legado.app.help.config.AppConfig
 
 /**
  * 设置是否夜间模式
@@ -20,20 +19,18 @@ fun WebSettings.setDarkeningAllowed(allow: Boolean) {
             it.printOnDebug()
         }
     }
-    if (AppConfig.isNightTheme) {
-        if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) {
-            @Suppress("DEPRECATION")
-            WebSettingsCompat.setForceDarkStrategy(
-                this,
-                WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING
-            )
-        }
-        if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
-            @Suppress("DEPRECATION")
-            WebSettingsCompat.setForceDark(
-                this,
-                WebSettingsCompat.FORCE_DARK_ON
-            )
-        }
+    if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) {
+        @Suppress("DEPRECATION")
+        WebSettingsCompat.setForceDarkStrategy(
+            this,
+            WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING
+        )
+    }
+    if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
+        @Suppress("DEPRECATION")
+        WebSettingsCompat.setForceDark(
+            this,
+            if (allow) WebSettingsCompat.FORCE_DARK_ON else WebSettingsCompat.FORCE_DARK_OFF
+        )
     }
-}
+}

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -2508,6 +2508,7 @@
     <string name="read_books_prefix">已读 </string>
     <string name="read_books_suffix"> 本书</string>
     <string name="hours_minutes_format">%1$d小时%2$d分钟</string>
+    <string name="whole_hours_format">%1$d小时</string>
     <string name="minutes_format">%1$d分钟</string>
     <string name="total_reading_time_format">共阅读 %1$s</string>
     <string name="read_duration_done">已读 %1$s</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2411,6 +2411,7 @@
     <string name="read_books_prefix">已讀 </string>
     <string name="read_books_suffix"> 本書</string>
     <string name="hours_minutes_format">%1$d小時%2$d分鐘</string>
+    <string name="whole_hours_format">%1$d小時</string>
     <string name="minutes_format">%1$d分鐘</string>
     <string name="total_reading_time_format">共閱讀 %1$s</string>
     <string name="read_duration_done">已讀 %1$s</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -2416,6 +2416,7 @@
     <string name="read_books_prefix">已讀 </string>
     <string name="read_books_suffix"> 本書</string>
     <string name="hours_minutes_format">%1$d小時%2$d分鐘</string>
+    <string name="whole_hours_format">%1$d小時</string>
     <string name="minutes_format">%1$d分鐘</string>
     <string name="total_reading_time_format">共閱讀 %1$s</string>
     <string name="read_duration_done">已讀 %1$s</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -2519,6 +2519,7 @@
     <string name="read_books_prefix">Read </string>
     <string name="read_books_suffix"> books</string>
     <string name="hours_minutes_format">%1$d h %2$d min</string>
+    <string name="whole_hours_format">%1$d hours</string>
     <string name="minutes_format">%1$d min</string>
     <string name="total_reading_time_format">Total reading %1$s</string>
     <string name="read_duration_done">Read %1$s</string>

+ 2 - 1
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -120,6 +120,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(adaptSpecialStyle = false),
         base.copy(useZhLayout = true),
         base.copy(eyeProtectionEnabled = true),
+        base.copy(eyeProtectionIntensity = 87),
         base.copy(eyeProtectionAutoNight = true),
         base.copy(brightnessAuto = true),
         base.copy(useUnderline = true),
@@ -157,7 +158,7 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity,
+    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
     PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,

+ 25 - 0
app/src/test/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCaseTest.kt

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class SyncReadAloudVoicesUseCaseTest {
@@ -65,6 +66,30 @@ class SyncReadAloudVoicesUseCaseTest {
         assertFalse(gateway.voices.single().available)
         assertFalse(gateway.voices.single().enabled)
     }
+
+    @Test
+    fun `missing configured HTTP voice is removed with deleted TTS`() = runBlocking {
+        val old = ReadAloudVoice(
+            id = SpeechIdentity.voiceId("http", "7", ""),
+            engineType = ReadAloudVoice.ENGINE_HTTP,
+            engineId = "7",
+            speakerId = "",
+            displayName = "deleted TTS",
+            managedBy = ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS,
+            available = false,
+        )
+        val gateway = MutableVoiceGateway(mutableListOf(old))
+
+        val result = SyncReadAloudVoicesUseCase(gateway)(
+            entries = emptyList(),
+            managedSources = setOf(ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS),
+            removeMissingEngineTypes = setOf(ReadAloudVoice.ENGINE_HTTP),
+            now = 200,
+        )
+
+        assertEquals(1, result.unavailable)
+        assertTrue(gateway.voices.isEmpty())
+    }
 }
 
 private class MutableVoiceGateway(

+ 15 - 1
app/src/test/java/io/legado/app/help/config/ThemePackageManagerRollbackTest.kt

@@ -93,15 +93,18 @@ class ThemePackageManagerRollbackTest {
     }
 
     @Test
-    fun `应用已保存主题失败会删除本次导入的封面图集`() = runBlocking {
+    fun `应用已保存主题失败会清理本次复制的资源和封面图集`() = runBlocking {
+        File(application.getExternalFilesDir(null), "theme_assets").deleteRecursively()
         val packageRoot = File(application.cacheDir, "saved-theme-package").apply {
             deleteRecursively()
             mkdirs()
         }
         File(packageRoot, "cover.png").writeBytes("cover".toByteArray())
+        File(packageRoot, "background.png").writeBytes("background".toByteArray())
         val manifest = ThemePackageManifest(
             name = "saved",
             config = ThemeExportData(appTheme = "new"),
+            assets = mapOf("background.light" to "background.png"),
             coverAlbums = listOf(
                 ThemePackageCoverAlbum(
                     ref = "album-ref",
@@ -133,6 +136,15 @@ class ThemePackageManagerRollbackTest {
         assertEquals("old-album", coverGateway.selection.value.albumId)
         assertEquals(1, coverGateway.deletedAlbumIds.size)
         assertFalse(File(coverGateway.rootDir, coverGateway.deletedAlbumIds.single()).exists())
+        val copiedBackground = File(requireNotNull(settingsGateway.attempts.first().bgImageLight))
+        assertFalse(copiedBackground.absolutePath.startsWith(packageRoot.absolutePath))
+        assertFalse(copiedBackground.exists())
+        assertTrue(
+            File(application.getExternalFilesDir(null), "theme_assets")
+                .listFiles()
+                .orEmpty()
+                .isEmpty()
+        )
     }
 
     @Test
@@ -184,12 +196,14 @@ class ThemePackageManagerRollbackTest {
         initial: ThemeExportData,
         private val failure: () -> Exception = { IOException("apply failed") },
     ) : ThemePackageSettingsGateway {
+        val attempts = mutableListOf<ThemeExportData>()
         var current: ThemeExportData = initial
             private set
 
         override fun exportCurrent(): ThemeExportData = current
 
         override suspend fun applyAndAwait(data: ThemeExportData) {
+            attempts += data
             throw failure()
         }
     }

+ 53 - 0
app/src/test/java/io/legado/app/ui/book/read/EyeProtectionTest.kt

@@ -2,8 +2,11 @@ package io.legado.app.ui.book.read
 
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
 import org.junit.Test
+import java.time.LocalTime
 
 class EyeProtectionTest {
 
@@ -41,4 +44,54 @@ class EyeProtectionTest {
         assertEquals(false, EyeProtection.syncEnabledForNight(isNight = false, autoNight = true))
         assertNull(EyeProtection.syncEnabledForNight(isNight = true, autoNight = false))
     }
+
+    private fun activeAt(
+        hour: Int,
+        minute: Int = 0,
+        enabled: Boolean = true,
+        schedule: Boolean = true,
+        start: String = "22:00",
+        end: String = "07:00",
+    ) = EyeProtection.isActiveAt(enabled, schedule, start, end, LocalTime.of(hour, minute))
+
+    @Test
+    fun `总开关关闭时永不生效`() {
+        assertFalse(activeAt(23, enabled = false))
+        assertFalse(activeAt(23, enabled = false, schedule = false))
+    }
+
+    @Test
+    fun `未启用定时时总开关开即全天生效`() {
+        assertTrue(activeAt(3, schedule = false))
+        assertTrue(activeAt(15, schedule = false))
+    }
+
+    @Test
+    fun `跨零点时段仅在窗口内生效`() {
+        assertTrue(activeAt(22))       // 起点
+        assertTrue(activeAt(23, 30))   // 午夜前
+        assertTrue(activeAt(0, 30))    // 午夜后
+        assertTrue(activeAt(6, 59))    // 终点前
+        assertFalse(activeAt(7))       // 终点(不含)
+        assertFalse(activeAt(12))      // 白天
+        assertFalse(activeAt(21, 59))  // 起点前
+    }
+
+    @Test
+    fun `同日时段仅在窗口内生效`() {
+        assertTrue(activeAt(9, start = "08:00", end = "18:00"))
+        assertFalse(activeAt(7, 59, start = "08:00", end = "18:00"))
+        assertFalse(activeAt(18, start = "08:00", end = "18:00"))
+    }
+
+    @Test
+    fun `时间无法解析时退化为按总开关生效`() {
+        assertTrue(activeAt(12, start = "bad", end = "07:00"))
+        assertTrue(activeAt(12, start = "22:00", end = ""))
+    }
+
+    @Test
+    fun `起止时间相同视为全天生效`() {
+        assertTrue(activeAt(3, start = "08:00", end = "08:00"))
+    }
 }

+ 23 - 0
app/src/test/java/io/legado/app/ui/book/readRecord/ReadRecordFormatterTest.kt

@@ -0,0 +1,23 @@
+package io.legado.app.ui.book.readRecord
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReadRecordFormatterTest {
+
+    @Test
+    fun hourMinuteDuration_discardsSeconds() {
+        assertEquals(
+            ReadRecordFormatter.HourMinuteDuration(hours = 1, minutes = 30),
+            ReadRecordFormatter.hourMinuteDuration(5_400_999L),
+        )
+    }
+
+    @Test
+    fun hourMinuteDuration_doesNotConvertLongDurationsToDays() {
+        assertEquals(
+            ReadRecordFormatter.HourMinuteDuration(hours = 49, minutes = 5),
+            ReadRecordFormatter.hourMinuteDuration((49L * 60 + 5) * 60 * 1_000),
+        )
+    }
+}