Sfoglia il codice sorgente

Merge remote-tracking branch 'fork-5151561/fix/app-font-switch-and-role-voice' into merge-1847

# Conflicts:
#	app/src/test/java/io/legado/app/ui/main/MainNavigatorReadAloudTest.kt
HapeLee 3 mesi fa
parent
commit
68594be459
64 ha cambiato i file con 17189 aggiunte e 11982 eliminazioni
  1. 8078 5645
      app/src/appRelease/generated/baselineProfiles/baseline-prof.txt
  2. 8078 5645
      app/src/appRelease/generated/baselineProfiles/startup-prof.txt
  3. 15 1
      app/src/main/java/io/legado/app/App.kt
  4. 2 0
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  5. 11 0
      app/src/main/java/io/legado/app/data/repository/BookGroupMutationRepository.kt
  6. 3 1
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  7. 0 9
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  8. 1 0
      app/src/main/java/io/legado/app/di/appModule.kt
  9. 2 0
      app/src/main/java/io/legado/app/domain/gateway/BookGroupMutationGateway.kt
  10. 0 3
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  11. 4 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  12. 18 4
      app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt
  13. 3 9
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  14. 2 1
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  15. 1 1
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  16. 7 4
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  17. 1 4
      app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt
  18. 7 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  19. 28 12
      app/src/main/java/io/legado/app/ui/book/read/EyeProtection.kt
  20. 29 5
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  21. 15 4
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  22. 11 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  23. 9 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  24. 81 36
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  25. 104 18
      app/src/main/java/io/legado/app/ui/book/read/sheet/EyeProtectionConfigSheet.kt
  26. 78 73
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt
  27. 2 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt
  28. 2 2
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt
  29. 4 0
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
  30. 32 20
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt
  31. 5 3
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt
  32. 10 4
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt
  33. 63 15
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
  34. 4 4
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt
  35. 0 3
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigRouteScreen.kt
  36. 30 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  37. 79 26
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  38. 4 2
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  39. 1 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  40. 0 10
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  41. 5 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  42. 3 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  43. 0 9
      app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt
  44. 2 3
      app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt
  45. 2 0
      app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt
  46. 18 12
      app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt
  47. 69 0
      app/src/main/java/io/legado/app/ui/widget/components/dialog/TimePickerDialog.kt
  48. 3 24
      app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt
  49. 12 0
      app/src/main/java/io/legado/app/utils/HtmlFormatter.kt
  50. 6 1
      app/src/main/java/io/legado/app/utils/RegexExtensions.kt
  51. 36 11
      app/src/main/java/io/legado/app/utils/WebSettingsExtensions.kt
  52. 0 287
      app/src/main/res/layout/dialog_tip_config.xml
  53. 2 2
      app/src/main/res/values-zh-rCN/strings.xml
  54. 2 2
      app/src/main/res/values-zh-rHK/strings.xml
  55. 2 2
      app/src/main/res/values-zh-rTW/strings.xml
  56. 2 2
      app/src/main/res/values/strings.xml
  57. 48 0
      app/src/test/java/io/legado/app/data/repository/BookGroupMutationRepositoryTest.kt
  58. 3 10
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  59. 20 3
      app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt
  60. 34 10
      app/src/test/java/io/legado/app/ui/book/read/EyeProtectionTest.kt
  61. 2 0
      app/src/test/java/io/legado/app/ui/main/MainNavigatorReadAloudTest.kt
  62. 23 0
      app/src/test/java/io/legado/app/ui/widget/components/dialog/TimePickerDialogTest.kt
  63. 31 0
      app/src/test/java/io/legado/app/utils/HtmlFormatterTest.kt
  64. 40 26
      baselineProfile/src/main/java/io/legado/baselineprofile/BaselineProfileGenerator.kt

File diff suppressed because it is too large
+ 8078 - 5645
app/src/appRelease/generated/baselineProfiles/baseline-prof.txt


File diff suppressed because it is too large
+ 8078 - 5645
app/src/appRelease/generated/baselineProfiles/startup-prof.txt


+ 15 - 1
app/src/main/java/io/legado/app/App.kt

@@ -35,8 +35,9 @@ import io.legado.app.data.entities.rule.ExploreRule
 import io.legado.app.data.entities.rule.SearchRule
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appModule
-import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
@@ -67,7 +68,9 @@ import io.legado.app.utils.LogUtils
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isDebuggable
+import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
 import org.chromium.base.ThreadUtils
@@ -172,6 +175,17 @@ class App : Application(), ImageLoaderFactory {
                 .distinctUntilChanged()
                 .collect { AppWebDav.upConfig() }
         }
+        // themeMode 是日夜的唯一来源,除外观设置外(阅读页快捷按钮、主题包、恢复备份)
+        // 也会直接写网关。AppCompat 的夜间模式统一跟随网关,否则资源配置不变,
+        // WebView、旧 View 界面拿到的仍是切换前的深浅色。
+        Coroutine.async {
+            get<AppShellSettingsGateway>().settings
+                .map { it.themeMode }
+                .distinctUntilChanged()
+                .collect {
+                    withContext(Main) { ThemeConfigStore.initNightMode() }
+                }
+        }
         Coroutine.async {
             LogUtils.init(this@App)
             LogUtils.d("App", "onCreate")

+ 2 - 0
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -84,6 +84,8 @@ abstract class BaseComposeActivity(
                     .collectAsStateWithLifecycle(initialThemeSettings)
                 val eyeProtectionActive = rememberEyeProtectionActive(
                     enabled = themeSettings.eyeProtectionEnabled,
+                    autoNight = themeSettings.eyeProtectionAutoNight,
+                    isDark = uiConfiguration.isDarkTheme,
                     schedule = themeSettings.eyeProtectionSchedule,
                     startTime = themeSettings.eyeProtectionStartTime,
                     endTime = themeSettings.eyeProtectionEndTime,

+ 11 - 0
app/src/main/java/io/legado/app/data/repository/BookGroupMutationRepository.kt

@@ -82,6 +82,17 @@ class BookGroupMutationRepository(
         }
     }
 
+    override suspend fun deleteGroup(groupId: Long) {
+        database.withTransaction {
+            val group = database.bookGroupDao.getByID(groupId) ?: return@withTransaction
+            database.tagGroupRuleDao.getByGroupName(group.groupName)?.let {
+                database.tagGroupRuleDao.delete(it)
+            }
+            database.bookDao.removeGroup(groupId)
+            database.bookGroupDao.delete(group)
+        }
+    }
+
     private suspend fun upsertTagGroupRule(rule: TagGroupRuleUpdate) {
         val ruleDao = database.tagGroupRuleDao
         val existing = ruleDao.getByGroupName(rule.groupName)

+ 3 - 1
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -391,6 +391,7 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     itemDividerColor = compatDsInt(PreferKey.itemDividerColor) ?: 0,
     eyeProtectionEnabled = compatDsBoolean(PreferKey.eyeProtectionEnabled) ?: false,
     colorTemperature = compatDsInt(PreferKey.colorTemperature) ?: 50,
+    eyeProtectionAutoNight = compatDsBoolean(PreferKey.eyeProtectionAutoNight) ?: false,
     eyeProtectionSchedule = compatDsBoolean(PreferKey.eyeProtectionSchedule) ?: false,
     eyeProtectionStartTime = compatDsString(PreferKey.eyeProtectionStartTime) ?: "22:00",
     eyeProtectionEndTime = compatDsString(PreferKey.eyeProtectionEndTime) ?: "07:00",
@@ -402,7 +403,7 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
 )
 
 /**
- * Theme gateway 的 59 键写入边界。主题包专用的 [ThemeSettings.customMode] 与
+ * Theme gateway 的 60 键写入边界。主题包专用的 [ThemeSettings.customMode] 与
  * [ThemeSettings.bookInfoInputColor] 由 ThemePackageSettingsGateway 的事务路径持久化。
  */
 internal fun ThemeSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
@@ -459,6 +460,7 @@ internal fun ThemeSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.itemDividerColor to itemDividerColor,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
     PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
     PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
     PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,

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

@@ -344,9 +344,6 @@ class ReadSettingsRepository(
             textBottomJustify = compatDsValue(Keys.TextBottomJustify, true),
             adaptSpecialStyle = compatDsValue(Keys.AdaptSpecialStyle, true),
             useZhLayout = compatDsValue(Keys.UseZhLayout, false),
-            eyeProtectionEnabled = compatDsValue(Keys.EyeProtectionEnabled, false),
-            eyeProtectionIntensity = compatDsValue(Keys.EyeProtectionIntensity, 50).coerceIn(0, 100),
-            eyeProtectionAutoNight = compatDsValue(Keys.EyeProtectionAutoNight, false),
             showBrightnessView = compatDsValue(Keys.ShowBrightnessView, "0"),
             brightnessVwPos = compatDsValue(Keys.BrightnessVwPos, "1"),
             readBrightness = compatDsValue(Keys.ReadBrightness, 100),
@@ -459,9 +456,6 @@ class ReadSettingsRepository(
         val TextBottomJustify = booleanPreferencesKey(PreferKey.textBottomJustify)
         val AdaptSpecialStyle = booleanPreferencesKey(PreferKey.adaptSpecialStyle)
         val UseZhLayout = booleanPreferencesKey(PreferKey.useZhLayout)
-        val EyeProtectionEnabled = booleanPreferencesKey(PreferKey.eyeProtectionEnabled)
-        val EyeProtectionIntensity = intPreferencesKey(PreferKey.colorTemperature)
-        val EyeProtectionAutoNight = booleanPreferencesKey(PreferKey.eyeProtectionAutoNight)
         val ShowBrightnessView = stringPreferencesKey(PreferKey.showBrightnessView)
         val BrightnessVwPos = stringPreferencesKey(PreferKey.brightnessVwPos)
         val ReadBrightness = intPreferencesKey(PreferKey.brightness)
@@ -576,9 +570,6 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
-    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
-    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

+ 1 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -601,6 +601,7 @@ val appModule = module {
             otherSettingsGateway = get(),
             downloadCacheSettingsGateway = get(),
             backupSettingsGateway = get(),
+            themeSettingsGateway = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)

+ 2 - 0
app/src/main/java/io/legado/app/domain/gateway/BookGroupMutationGateway.kt

@@ -17,4 +17,6 @@ interface BookGroupMutationGateway {
     suspend fun saveTagGroupRule(rule: TagGroupRuleUpdate)
 
     suspend fun deleteTagGroupRule(ruleId: Long)
+
+    suspend fun deleteGroup(groupId: Long)
 }

+ 0 - 3
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -17,9 +17,6 @@ data class ReadSettings(
     val textBottomJustify: Boolean = true,
     val adaptSpecialStyle: Boolean = true,
     val useZhLayout: Boolean = false,
-    val eyeProtectionEnabled: Boolean = false,
-    val eyeProtectionIntensity: Int = 50,
-    val eyeProtectionAutoNight: Boolean = false,
     val showBrightnessView: String = "0",
     val brightnessVwPos: String = "1",
     val readBrightness: Int = 100,

+ 4 - 0
app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt

@@ -56,6 +56,7 @@ data class ThemeSettings(
     val itemDividerColor: Int = 0,
     val eyeProtectionEnabled: Boolean = false,
     val colorTemperature: Int = 50,
+    val eyeProtectionAutoNight: Boolean = false,
     val eyeProtectionSchedule: Boolean = false,
     val eyeProtectionStartTime: String = "22:00",
     val eyeProtectionEndTime: String = "07:00",
@@ -64,6 +65,9 @@ data class ThemeSettings(
     val customTagColorsJson: String? = null,
 )
 
+val ThemeSettings.isEyeProtectionConfigured: Boolean
+    get() = eyeProtectionEnabled || eyeProtectionAutoNight
+
 data class ThemeCustomColors(
     val primary: Int,
     val secondary: Int,

+ 18 - 4
app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt

@@ -37,6 +37,11 @@ class BuildSpeechPlanUseCase(
             ?: narrator
 
         return segments.map { segment ->
+            val performance = segment.characterId?.let(characterPerformances::get)
+            // 角色(男主/女主/男配/女配)绑定的音色,优先级低于角色专属绑定、高于性别兜底
+            val roleVoice = performance?.roleSubject()?.let { subject ->
+                bindings.voice(subject, subject, voicesById)
+            }
             val primary = if (!useMultiSpeaker) {
                 // Multi-speaker disabled — every segment uses the default voice
                 defaultVoice
@@ -48,7 +53,6 @@ class BuildSpeechPlanUseCase(
                         voicesById,
                     )
                 }
-                val performance = segment.characterId?.let(characterPerformances::get)
                 val genderFallback = when (performance?.resolvedGender()) {
                     "male" -> bindings.voice(
                         BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
@@ -65,7 +69,7 @@ class BuildSpeechPlanUseCase(
                 }
                 when (segment.roleType) {
                     SpeechRoleType.Character,
-                    SpeechRoleType.Thought -> characterVoice ?: genderFallback
+                    SpeechRoleType.Thought -> characterVoice ?: roleVoice ?: genderFallback
                         ?: unknown ?: narrator ?: defaultVoice
                     SpeechRoleType.Unknown -> unknown ?: narrator ?: defaultVoice
                     SpeechRoleType.Narrator -> narrator ?: defaultVoice
@@ -76,7 +80,7 @@ class BuildSpeechPlanUseCase(
             } else {
                 buildList {
                     if (segment.roleType != SpeechRoleType.Narrator) {
-                        val performance = segment.characterId?.let(characterPerformances::get)
+                        add(roleVoice)
                         add(
                             when (performance?.resolvedGender()) {
                                 "male" -> bindings.voice(
@@ -104,7 +108,7 @@ class BuildSpeechPlanUseCase(
                 segment = segment,
                 voice = primary,
                 fallbackVoices = fallbackVoices,
-                characterPerformance = segment.characterId?.let(characterPerformances::get),
+                characterPerformance = performance,
             )
         }
     }
@@ -115,6 +119,16 @@ class BuildSpeechPlanUseCase(
         voicesById: Map<String, ReadAloudVoice>,
     ): ReadAloudVoice? = get(subjectType to subjectId)?.voiceId?.let(voicesById::get)
 
+    /** 只有已知的角色标识才对应绑定主体,避免任意角色文本参与查询 */
+    private fun CharacterPerformanceProfile.roleSubject(): String? = when (role) {
+        BookVoiceBinding.SUBJECT_MALE_LEAD,
+        BookVoiceBinding.SUBJECT_FEMALE_LEAD,
+        BookVoiceBinding.SUBJECT_MALE_SUPPORTING,
+        BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> role
+
+        else -> null
+    }
+
     private fun CharacterPerformanceProfile.resolvedGender(): String? = when (voiceGender) {
         "male", "female" -> voiceGender
         else -> when (role) {

+ 3 - 9
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -461,9 +461,6 @@ fun applyTagGroupRules(
         }
     }
 
-    // Mask of all group IDs managed by tag group rules
-    val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
-
     val updatedBooks = mutableListOf<Book>()
     for (book in books) {
         val kinds = book.getDisplayTagList()
@@ -473,9 +470,8 @@ fun applyTagGroupRules(
                 newGroupMask = newGroupMask or (groupCache[rule.groupName] ?: 0L)
             }
         }
-        // Clear old rule-managed bits, then set new ones
-        val clearedGroup = book.group and allRuleGroupMask.inv()
-        val finalGroup = clearedGroup or newGroupMask
+        // Tag rules only add matching groups; manually assigned groups are preserved.
+        val finalGroup = book.group or newGroupMask
         if (book.group != finalGroup) {
             book.group = finalGroup
             updatedBooks.add(book)
@@ -520,7 +516,6 @@ fun applyTagGroupRulesForBook(book: Book) {
         }
     }
 
-    val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
     val kinds = book.getDisplayTagList()
     var newGroupMask = 0L
     for ((rule, regex) in compiledRules) {
@@ -528,8 +523,7 @@ fun applyTagGroupRulesForBook(book: Book) {
             newGroupMask = newGroupMask or (groupCache[rule.groupName] ?: 0L)
         }
     }
-    val clearedGroup = book.group and allRuleGroupMask.inv()
-    val finalGroup = clearedGroup or newGroupMask
+    val finalGroup = book.group or newGroupMask
     if (book.group != finalGroup) {
         book.group = finalGroup
     }

+ 2 - 1
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -456,7 +456,8 @@ object ReadBookConfig {
         }
 
     var titleSegScaling: Float
-        get() = config.titleSegScaling
+        //旧版本可能存入负值,负值非法,回落到默认比例
+        get() = config.titleSegScaling.let { if (it < 0f) 1f else it.coerceAtMost(2f) }
         set(value) {
             config.titleSegScaling = value
         }

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

@@ -61,7 +61,7 @@ object ThemeConfigStore {
         initNightMode()
     }
 
-    private fun initNightMode() {
+    fun initNightMode() {
         ThemeConfig.initNightMode()
     }
 

+ 7 - 4
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -246,9 +246,6 @@ object BackupConfig {
         PreferKey.showMenuIcon,
         PreferKey.titleBarMode,
         PreferKey.shouldShowExpandButton,
-        PreferKey.eyeProtectionEnabled,
-        PreferKey.colorTemperature,
-        PreferKey.eyeProtectionAutoNight,
     )
 
     private val themePrefKeys = arrayOf(
@@ -295,7 +292,13 @@ object BackupConfig {
         PreferKey.primaryTextColorNight,
         PreferKey.secondaryTextColorNight,
         PreferKey.themeBackgroundColorNight,
-        PreferKey.labelContainerColorNight
+        PreferKey.labelContainerColorNight,
+        PreferKey.eyeProtectionEnabled,
+        PreferKey.colorTemperature,
+        PreferKey.eyeProtectionAutoNight,
+        PreferKey.eyeProtectionSchedule,
+        PreferKey.eyeProtectionStartTime,
+        PreferKey.eyeProtectionEndTime,
     )
 
     private val bookshelfPrefKeys = arrayOf(

+ 1 - 4
app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt

@@ -11,7 +11,6 @@ import io.legado.app.domain.gateway.BookGroupMutationGateway
 import io.legado.app.domain.model.BookGroupUpdate
 import io.legado.app.domain.model.NewBookGroup
 import io.legado.app.domain.model.TagGroupRuleUpdate
-import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.launch
@@ -19,7 +18,6 @@ import kotlinx.coroutines.launch
 class GroupViewModel(
     application: Application,
     private val bookGroupRepository: BookGroupRepository,
-    private val removeBookGroupAssignmentUseCase: RemoveBookGroupAssignmentUseCase,
     private val bookGroupMutationGateway: BookGroupMutationGateway,
 ) : BaseViewModel(application) {
 
@@ -87,8 +85,7 @@ class GroupViewModel(
 
     fun delGroup(bookGroup: BookGroup, finally: () -> Unit) {
         execute {
-            bookGroupRepository.delete(bookGroup)
-            removeBookGroupAssignmentUseCase.execute(bookGroup.groupId)
+            bookGroupMutationGateway.deleteGroup(bookGroup.groupId)
         }.onFinally {
             finally()
         }

+ 7 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -64,6 +64,7 @@ import io.legado.app.ui.main.MainIntent
 import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.GSON
+import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.fromJsonArray
@@ -1042,7 +1043,11 @@ class BookInfoViewModel(
     private fun onShelfClick() {
         val book = currentBook ?: return
         if (inBookshelf) {
-            showDialog(BookInfoDialog.DeleteBook(book.isLocal))
+            if (LocalConfig.bookInfoDeleteAlert) {
+                showDialog(BookInfoDialog.DeleteBook(book.isLocal))
+            } else {
+                deleteBook(LocalConfig.deleteBookOriginal)
+            }
         } else if (book.isWebFile) {
             setSheet(BookInfoSheet.WebFiles(openAfterImport = false))
         } else {
@@ -1708,7 +1713,7 @@ class BookInfoViewModel(
             durChapterIndex = durChapterIndex,
             durChapterPos = durChapterPos,
             remark = remark,
-            displayIntro = getDisplayIntro(),
+            displayIntro = HtmlFormatter.formatDisplayText(getDisplayIntro()),
         )
     }
 

+ 28 - 12
app/src/main/java/io/legado/app/ui/book/read/EyeProtection.kt

@@ -49,24 +49,23 @@ object EyeProtection {
         )
     }
 
-    fun syncEnabledForNight(isNight: Boolean, autoNight: Boolean): Boolean? =
-        if (autoNight) isNight else null
-
     /**
-     * 计算护眼在给定时刻是否应生效。
-     * - 总开关关闭 → 永远不生效;
-     * - 未启用定时 → 只要总开关开就生效;
-     * - 启用定时但时间无法解析 → 退化为仅按总开关生效;
+     * 计算护眼在给定时刻是否应生效。全 App 共用这一处判定,不落盘、不回写总开关。
+     * - 手动开关开启,或「跟随深色模式」开启且当前为深色 → 进入护眼候选状态;
+     * - 未启用定时 → 上一条为真即生效;
+     * - 启用定时但时间无法解析 → 退化为不看时段;
      * - 启用定时 → 仅在 [startTime, endTime) 时段内生效(支持跨零点,如 22:00–07:00)。
      */
     fun isActiveAt(
         enabled: Boolean,
+        autoNight: Boolean,
+        isDark: Boolean,
         schedule: Boolean,
         startTime: String,
         endTime: String,
         now: LocalTime,
     ): Boolean {
-        if (!enabled) return false
+        if (!(enabled || (autoNight && isDark))) return false
         if (!schedule) return true
         val start = parseTime(startTime) ?: return true
         val end = parseTime(endTime) ?: return true
@@ -78,8 +77,15 @@ object EyeProtection {
         }
     }
 
-    private fun parseTime(value: String): LocalTime? =
-        runCatching { LocalTime.parse(value.trim()) }.getOrNull()
+    private fun parseTime(value: String): LocalTime? = runCatching {
+        val normalized = buildString {
+            value.trim().forEach { char ->
+                val digit = Character.digit(char, 10)
+                append(if (digit >= 0) ('0'.code + digit).toChar() else char)
+            }
+        }
+        LocalTime.parse(normalized)
+    }.getOrNull()
 
     private val IDENTITY_MATRIX = floatArrayOf(
         1f, 0f, 0f, 0f, 0f,
@@ -116,11 +122,13 @@ fun Modifier.eyeProtectionColorFilter(
 @Composable
 fun rememberEyeProtectionActive(
     enabled: Boolean,
+    autoNight: Boolean,
+    isDark: Boolean,
     schedule: Boolean,
     startTime: String,
     endTime: String,
 ): Boolean {
-    if (!enabled) return false
+    if (!(enabled || (autoNight && isDark))) return false
     if (!schedule) return true
     var now by remember { mutableStateOf(LocalTime.now()) }
     LaunchedEffect(startTime, endTime) {
@@ -129,5 +137,13 @@ fun rememberEyeProtectionActive(
             delay(60_000L)
         }
     }
-    return EyeProtection.isActiveAt(enabled, schedule, startTime, endTime, now)
+    return EyeProtection.isActiveAt(
+        enabled = enabled,
+        autoNight = autoNight,
+        isDark = isDark,
+        schedule = schedule,
+        startTime = startTime,
+        endTime = endTime,
+        now = now,
+    )
 }

+ 29 - 5
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -332,11 +332,26 @@ data class ReadBookUiState(
     val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
     val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
     val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+    val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
 ) {
     val menuVisible: Boolean
         get() = menuState.visible
 }
 
+/** 护眼模式设置,来源是 ThemeSettings,与外观设置共用同一份值。 */
+@Stable
+data class EyeProtectionUiState(
+    val enabled: Boolean = false,
+    val intensity: Int = 50,
+    val autoNight: Boolean = false,
+    val schedule: Boolean = false,
+    val startTime: String = "22:00",
+    val endTime: String = "07:00",
+) {
+    val configured: Boolean
+        get() = enabled || autoNight
+}
+
 @Stable
 data class HighlightRuleConfigUiState(
     val rules: ImmutableList<HighlightRule> = persistentListOf(),
@@ -463,7 +478,10 @@ sealed interface ReadBookIntent {
     data object ReadMenuBack : ReadBookIntent
 
     // Search
-    data class OpenSearch(val word: String?) : ReadBookIntent
+    data class OpenSearch(
+        val word: String?,
+        val autoFocus: Boolean = true,
+    ) : ReadBookIntent
     data object ExitSearch : ReadBookIntent
     data object ShowSearchMenu : ReadBookIntent
     data object HideSearchMenu : ReadBookIntent
@@ -667,7 +685,9 @@ sealed interface ReadBookIntent {
     data class EyeProtectionEnabledChanged(val value: Boolean) : ReadBookIntent
     data class EyeProtectionIntensityChanged(val value: Int) : ReadBookIntent
     data class EyeProtectionAutoNightChanged(val value: Boolean) : ReadBookIntent
-    data class SyncEyeProtectionForTheme(val isNight: Boolean) : ReadBookIntent
+    data class EyeProtectionScheduleChanged(val value: Boolean) : ReadBookIntent
+    data class EyeProtectionStartTimeChanged(val value: String) : ReadBookIntent
+    data class EyeProtectionEndTimeChanged(val value: String) : ReadBookIntent
 
     // Default font picker (needs Activity for AlertDialog)
     // Text action menu (moved from Activity)
@@ -848,7 +868,11 @@ sealed interface ReadBookEffect {
     data object StopAutoPage : ReadBookEffect
 
     // Search
-    data class OpenSearchActivity(val word: String?, val bookUrl: String) : ReadBookEffect
+    data class OpenSearchActivity(
+        val word: String?,
+        val bookUrl: String,
+        val autoFocus: Boolean = true,
+    ) : ReadBookEffect
     data class NavigateToSearchResult(
         val result: SearchResult,
         val chapterIndex: Int,
@@ -1291,7 +1315,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout)
     }
     data class UnderlineColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+        override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask)
     }
 
     // --- Body padding ---
@@ -1365,7 +1389,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateBackgroundAlpha)
     }
     data class StatusIconDark(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
     data class StyleName(val value: String) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()

+ 15 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -194,6 +194,7 @@ import kotlin.math.roundToInt
 fun ReadBookMenuBar(
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     onIntent: (ReadBookIntent) -> Unit,
     onBrightnessPreview: (Int) -> Unit,
     backdrop: Backdrop? = null,
@@ -262,6 +263,7 @@ fun ReadBookMenuBar(
                     FloatingIconRow(
                         state = state,
                         preferences = preferences,
+                        eyeProtectionActive = eyeProtectionActive,
                         colors = menuColors,
                         alignment = if (state.menuConfig.titleBarIconPosition == 0) {
                             Alignment.Start
@@ -377,6 +379,7 @@ fun ReadBookMenuBar(
                     FloatingIconRow(
                         state = state,
                         preferences = preferences,
+                        eyeProtectionActive = eyeProtectionActive,
                         colors = menuColors,
                         alignment = if (state.menuConfig.titleBarIconPosition == 2) {
                             Alignment.Start
@@ -391,6 +394,7 @@ fun ReadBookMenuBar(
                     contentTarget = contentTarget,
                     state = state,
                     preferences = preferences,
+                    eyeProtectionActive = eyeProtectionActive,
                     colors = menuColors,
                     onIntent = onIntent,
                     context = context,
@@ -416,6 +420,7 @@ private fun ReadBookMenuSurface(
     contentTarget: ReadBookMenuContent,
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     colors: ReadMenuColors,
     onIntent: (ReadBookIntent) -> Unit,
     context: Context,
@@ -672,7 +677,7 @@ private fun ReadBookMenuSurface(
                     ReadBookMenuRoute.Main -> {
                     MenuBottomBar(
                         state = state,
-                        eyeProtectionEnabled = preferences.eyeProtectionEnabled,
+                        eyeProtectionEnabled = eyeProtectionActive,
                         colors = colors,
                         onIntent = onIntent,
                         context = context,
@@ -720,6 +725,7 @@ private fun ReadBookMenuSurface(
                                 readMenuCustomIcons = state.menuConfig.readMenuCustomIcons,
                                 bottomBarButtons = state.menuConfig.bottomBarButtons,
                                 preferences = preferences,
+                                eyeProtectionEnabled = eyeProtectionActive,
                                 onIntent = onIntent,
                                 styleConfig = state.styleConfig,
                             )
@@ -1934,6 +1940,7 @@ private fun CharsetActionButton(
 private fun FloatingIconRow(
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     colors: ReadMenuColors,
     alignment: Alignment.Horizontal = Alignment.CenterHorizontally,
     onIntent: (ReadBookIntent) -> Unit,
@@ -1946,12 +1953,13 @@ private fun FloatingIconRow(
         state.isAutoPage,
         state.translationMode,
         state.useReplaceRule,
-        preferences.eyeProtectionEnabled,
+        eyeProtectionActive,
     ) {
         loadFloatingIcons(
             context = context,
             state = state,
             preferences = preferences,
+            eyeProtectionActive = eyeProtectionActive,
             onIntent = onIntent,
         )
     }
@@ -2333,7 +2341,9 @@ private fun SearchBottomMenuContent(
                 modifier = Modifier.weight(2f),
                 icon = Icons.Default.Search,
                 text = stringResource(R.string.all_results),
-                onClick = { onIntent(ReadBookIntent.OpenSearch(null)) },
+                onClick = {
+                    onIntent(ReadBookIntent.OpenSearch(word = null, autoFocus = false))
+                },
             )
             SearchMenuActionButton(
                 modifier = Modifier.weight(1f),
@@ -3342,6 +3352,7 @@ private fun loadFloatingIcons(
     context: Context,
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     onIntent: (ReadBookIntent) -> Unit,
 ): List<FloatingIconDef> {
     val infoMap = readMenuButtonInfos(context).associateBy { it.id }
@@ -3383,7 +3394,7 @@ private fun loadFloatingIcons(
         if (state.isAutoPage) add("auto_page")
         if (state.translationMode) add("translate")
         if (state.useReplaceRule) add("replace")
-        if (preferences.eyeProtectionEnabled) add("eye_protection")
+        if (eyeProtectionActive) add("eye_protection")
     }
 
     return state.menuConfig.titleBarButtons

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

@@ -125,7 +125,7 @@ fun ReadBookRouteScreen(
     host: ReadBookRouteHost,
     controller: ReadBookController,
     onEffectsReady: () -> Unit = {},
-    onOpenSearch: (word: String?, bookUrl: String) -> Unit = { _, _ -> },
+    onOpenSearch: (word: String?, bookUrl: String, autoFocus: Boolean) -> Unit = { _, _, _ -> },
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
     onOpenTtsCache: () -> Unit = {},
@@ -136,6 +136,14 @@ fun ReadBookRouteScreen(
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val isDarkTheme = LocalAppUiConfiguration.current.isDarkTheme
+    val eyeProtectionActive = rememberEyeProtectionActive(
+        enabled = state.eyeProtection.enabled,
+        autoNight = state.eyeProtection.autoNight,
+        isDark = isDarkTheme,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
+    )
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
     val menuBackdrop = rememberLayerBackdrop()
     val menuHazeState = remember { HazeState() }
@@ -156,10 +164,6 @@ fun ReadBookRouteScreen(
         controller.onMenuVisibilityChanged(state.menuVisible)
     }
 
-    LaunchedEffect(isDarkTheme, readPreferences.eyeProtectionAutoNight) {
-        viewModel.onIntent(ReadBookIntent.SyncEyeProtectionForTheme(isDarkTheme))
-    }
-
     LaunchedEffect(viewModel, controller, lifecycleOwner) {
         lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
             viewModel.readAloudProgress.collect { chapterStart ->
@@ -371,7 +375,7 @@ fun ReadBookRouteScreen(
                                 }
                             }
                             is ReadBookEffect.OpenSearchActivity -> {
-                                onOpenSearch(effect.word, effect.bookUrl)
+                                onOpenSearch(effect.word, effect.bookUrl, effect.autoFocus)
                             }
                             is ReadBookEffect.OpenBookVoiceCasting -> {
                                 onOpenVoiceCasting(effect.bookUrl)
@@ -547,6 +551,7 @@ fun ReadBookRouteScreen(
             ReadBookMenuBar(
                 state = state,
                 preferences = readPreferences,
+                eyeProtectionActive = eyeProtectionActive,
                 onIntent = viewModel::onIntent,
                 onBrightnessPreview = host::previewBrightness,
                 backdrop = menuBackdrop,

+ 9 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -240,13 +240,19 @@ fun ReadBookScreen(
     )
     EyeProtectionConfigSheet(
         show = state.activeSheet is ReadBookSheet.EyeProtection,
-        enabled = preferences.eyeProtectionEnabled,
-        intensity = preferences.eyeProtectionIntensity,
-        autoNight = preferences.eyeProtectionAutoNight,
+        enabled = state.eyeProtection.enabled,
+        intensity = state.eyeProtection.intensity,
+        autoNight = state.eyeProtection.autoNight,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
         onDismissRequest = dismissSheet,
         onEnabledChange = { onIntent(ReadBookIntent.EyeProtectionEnabledChanged(it)) },
         onIntensityChange = { onIntent(ReadBookIntent.EyeProtectionIntensityChanged(it)) },
         onAutoNightChange = { onIntent(ReadBookIntent.EyeProtectionAutoNightChanged(it)) },
+        onScheduleChange = { onIntent(ReadBookIntent.EyeProtectionScheduleChanged(it)) },
+        onStartTimeChange = { onIntent(ReadBookIntent.EyeProtectionStartTimeChanged(it)) },
+        onEndTimeChange = { onIntent(ReadBookIntent.EyeProtectionEndTimeChanged(it)) },
     )
     FloatingBarIconConfigSheet(
         show = state.activeSheet is ReadBookSheet.FloatingBarIconConfig,

+ 81 - 36
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -54,7 +54,10 @@ import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ReadStyleIntKey
 import io.legado.app.domain.gateway.ReadStyleMutation
 import io.legado.app.domain.gateway.ReadStyleStringKey
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.TextProcessAction
@@ -200,6 +203,7 @@ class ReadBookViewModel(
     private val otherSettingsGateway: OtherSettingsGateway,
     private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
     private val backupSettingsGateway: BackupSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
     // --- MVI State ---
@@ -268,6 +272,7 @@ class ReadBookViewModel(
         ReadBook.register(this)
         refreshButtonConfigs()
         collectReadPreferences()
+        collectEyeProtectionSettings()
         collectReadAloudPreferences()
         collectEventBus()
         execute { syncConfiguredTtsVoices() }
@@ -338,7 +343,13 @@ class ReadBookViewModel(
                 closeReadMenu()
                 _uiState.update { it.copy(searchContentQuery = intent.word ?: "") }
                 ReadBook.book?.bookUrl?.let { bookUrl ->
-                    _effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.word, bookUrl))
+                    _effects.tryEmit(
+                        ReadBookEffect.OpenSearchActivity(
+                            word = intent.word,
+                            bookUrl = bookUrl,
+                            autoFocus = intent.autoFocus,
+                        )
+                    )
                 }
             }
 
@@ -1455,6 +1466,7 @@ class ReadBookViewModel(
                     _uiState.update {
                         it.copy(
                             styleConfig = buildStyleConfig(),
+                            sheetConfig = buildSheetConfig(),
                             activeSheet = null,
                         )
                     }
@@ -1472,7 +1484,12 @@ class ReadBookViewModel(
                 if (!readBookStyleConfigRepository.applyPreset(intent.presetIndex)) {
                     return@onIntent
                 }
-                _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
+                _uiState.update {
+                    it.copy(
+                        styleConfig = buildStyleConfig(),
+                        sheetConfig = buildSheetConfig(),
+                    )
+                }
                 _effects.tryEmit(
                     ReadBookEffect.UpdateReadViewConfig(
                         setOf(
@@ -1582,32 +1599,23 @@ class ReadBookViewModel(
             is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri)
 
             ReadBookIntent.ToggleEyeProtection -> toggleEyeProtection()
-            is ReadBookIntent.EyeProtectionEnabledChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionEnabled = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionEnabledChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEnabled = intent.value)
             }
-            is ReadBookIntent.EyeProtectionIntensityChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionIntensity = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionIntensityChanged -> updateEyeProtection {
+                it.copy(colorTemperature = intent.value.coerceIn(0, 100))
             }
-            is ReadBookIntent.EyeProtectionAutoNightChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionAutoNight = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionAutoNightChanged -> updateEyeProtection {
+                it.copy(eyeProtectionAutoNight = intent.value)
             }
-            is ReadBookIntent.SyncEyeProtectionForTheme -> {
-                EyeProtection.syncEnabledForNight(
-                    isNight = intent.isNight,
-                    autoNight = _readPreferences.value.eyeProtectionAutoNight,
-                )?.let { enabled ->
-                    viewModelScope.launch {
-                        readSettingsRepository.update {
-                            it.copy(eyeProtectionEnabled = enabled)
-                        }
-                    }
-                }
+            is ReadBookIntent.EyeProtectionScheduleChanged -> updateEyeProtection {
+                it.copy(eyeProtectionSchedule = intent.value)
+            }
+            is ReadBookIntent.EyeProtectionStartTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionStartTime = intent.value)
+            }
+            is ReadBookIntent.EyeProtectionEndTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEndTime = intent.value)
             }
         }
     }
@@ -2259,6 +2267,25 @@ class ReadBookViewModel(
         }
     }
 
+    private fun collectEyeProtectionSettings() {
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                _uiState.update {
+                    it.copy(
+                        eyeProtection = EyeProtectionUiState(
+                            enabled = settings.eyeProtectionEnabled,
+                            intensity = settings.colorTemperature,
+                            autoNight = settings.eyeProtectionAutoNight,
+                            schedule = settings.eyeProtectionSchedule,
+                            startTime = settings.eyeProtectionStartTime,
+                            endTime = settings.eyeProtectionEndTime,
+                        )
+                    )
+                }
+            }
+        }
+    }
+
     private suspend fun syncReadPreferencesSnapshot() {
         val preferences = readSettingsRepository.preferences.first()
         _readPreferences.value = preferences
@@ -2331,14 +2358,21 @@ class ReadBookViewModel(
         }
     }
 
-    private fun toggleEyeProtection() {
-        viewModelScope.launch {
-            readSettingsRepository.update {
-                it.copy(eyeProtectionEnabled = !_readPreferences.value.eyeProtectionEnabled)
-            }
+    private fun toggleEyeProtection() = updateEyeProtection {
+        if (it.isEyeProtectionConfigured) {
+            it.copy(
+                eyeProtectionEnabled = false,
+                eyeProtectionAutoNight = false,
+            )
+        } else {
+            it.copy(eyeProtectionEnabled = true)
         }
     }
 
+    private fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {
+        viewModelScope.launch { themeSettingsGateway.update(transform) }
+    }
+
     private fun ReadPreferences.hasMenuClickArea(): Boolean {
         return clickActionTL * clickActionTC * clickActionTR *
                 clickActionML * clickActionMC * clickActionMR *
@@ -5517,12 +5551,17 @@ class ReadBookViewModel(
             }
         }
 
-        // Notify rendering layer
+        // Notify rendering layer. styleConfig and sheetConfig are both derived
+        // from ReadBookConfig, so keep them rebuilt together — otherwise the
+        // settings sheet re-seeds its controls from a stale sheetConfig on reopen.
+        _uiState.update {
+            it.copy(
+                styleConfig = buildStyleConfig(),
+                sheetConfig = buildSheetConfig(),
+            )
+        }
         if (update.actions.isNotEmpty()) {
-            _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
             _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(update.actions))
-        } else {
-            _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
         }
     }
 
@@ -5989,7 +6028,8 @@ class ReadBookViewModel(
             }
             it.copy(
                 activeReminder = newActiveReminder,
-                styleConfig = buildStyleConfig()
+                styleConfig = buildStyleConfig(),
+                sheetConfig = buildSheetConfig(),
             )
         }
         reminderQueue.removeAll { it.type is ReminderType.DayNightReminder }
@@ -6055,7 +6095,12 @@ class ReadBookViewModel(
                 val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
                     ?: throw FileNotFoundException(uri.toString())
                 readBookStyleConfigRepository.importCurrentStyle(bytes)
-                _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
+                _uiState.update {
+                    it.copy(
+                        styleConfig = buildStyleConfig(),
+                        sheetConfig = buildSheetConfig(),
+                    )
+                }
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                     setOf(
                         ConfigUpdateAction.UpdateBackground,

+ 104 - 18
app/src/main/java/io/legado/app/ui/book/read/sheet/EyeProtectionConfigSheet.kt

@@ -1,47 +1,133 @@
 package io.legado.app.ui.book.read.sheet
 
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+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.res.stringResource
+import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.ui.widget.components.dialog.TimePickerDialog
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 
+/**
+ * 护眼模式设置。阅读菜单与阅读设置共用这一份,字段全部落在 ThemeSettings 上,
+ * 与外观设置里的护眼分组是同一套值。
+ */
 @Composable
 fun EyeProtectionConfigSheet(
     show: Boolean,
     enabled: Boolean,
     intensity: Int,
     autoNight: Boolean,
+    schedule: Boolean,
+    startTime: String,
+    endTime: String,
     onDismissRequest: () -> Unit,
     onEnabledChange: (Boolean) -> Unit,
     onIntensityChange: (Int) -> Unit,
     onAutoNightChange: (Boolean) -> Unit,
+    onScheduleChange: (Boolean) -> Unit,
+    onStartTimeChange: (String) -> Unit,
+    onEndTimeChange: (String) -> Unit,
 ) {
+    var editingStartTime by remember { mutableStateOf(false) }
+    var editingEndTime by remember { mutableStateOf(false) }
+    val configured = enabled || autoNight
+
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.eye_protection),
+        animateContentSize = false,
     ) {
-        SwitchSettingItem(
-            title = stringResource(R.string.eye_protection_enabled),
-            description = stringResource(R.string.eye_protection_enabled_summary),
-            checked = enabled,
-            onCheckedChange = onEnabledChange,
-        )
-        SliderSettingItem(
-            title = stringResource(R.string.eye_protection_intensity),
-            description = stringResource(R.string.eye_protection_intensity_summary, intensity),
-            value = intensity.toFloat(),
-            defaultValue = 50f,
-            valueRange = 0f..100f,
-            onValueChange = { onIntensityChange(it.toInt()) },
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(bottom = 16.dp),
+        ) {
+            SwitchSettingItem(
+                title = stringResource(R.string.eye_protection_enabled),
+                description = stringResource(R.string.eye_protection_enabled_summary),
+                checked = enabled,
+                onCheckedChange = onEnabledChange,
+            )
+            SwitchSettingItem(
+                title = stringResource(R.string.eye_protection_auto_night),
+                description = stringResource(R.string.eye_protection_auto_night_summary),
+                checked = autoNight,
+                onCheckedChange = onAutoNightChange,
+            )
+            AnimatedVisibility(visible = configured) {
+                Column {
+                    SliderSettingItem(
+                        title = stringResource(R.string.eye_protection_intensity),
+                        description = stringResource(
+                            R.string.eye_protection_intensity_summary,
+                            intensity
+                        ),
+                        value = intensity.toFloat(),
+                        defaultValue = 50f,
+                        valueRange = 0f..100f,
+                        onValueChange = { onIntensityChange(it.toInt()) },
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.eye_protection_schedule),
+                        description = stringResource(R.string.eye_protection_schedule_summary),
+                        checked = schedule,
+                        onCheckedChange = onScheduleChange,
+                    )
+                    AnimatedVisibility(visible = schedule) {
+                        Column {
+                            ClickableSettingItem(
+                                title = stringResource(R.string.eye_protection_start_time),
+                                option = startTime,
+                                onClick = { editingStartTime = true },
+                            )
+                            ClickableSettingItem(
+                                title = stringResource(R.string.eye_protection_end_time),
+                                option = endTime,
+                                onClick = { editingEndTime = true },
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    if (editingStartTime) {
+        TimePickerDialog(
+            title = stringResource(R.string.eye_protection_start_time),
+            currentValue = startTime,
+            onDismissRequest = { editingStartTime = false },
+            onConfirm = {
+                onStartTimeChange(it)
+                editingStartTime = false
+            },
         )
-        SwitchSettingItem(
-            title = stringResource(R.string.eye_protection_auto_night),
-            description = stringResource(R.string.eye_protection_auto_night_summary),
-            checked = autoNight,
-            onCheckedChange = onAutoNightChange,
+    }
+    if (editingEndTime) {
+        TimePickerDialog(
+            title = stringResource(R.string.eye_protection_end_time),
+            currentValue = endTime,
+            onDismissRequest = { editingEndTime = false },
+            onConfirm = {
+                onEndTimeChange(it)
+                editingEndTime = false
+            },
         )
     }
 }

+ 78 - 73
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

@@ -5,6 +5,7 @@ import android.net.Uri
 import android.provider.OpenableColumns
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
@@ -304,7 +305,7 @@ fun HighlightRuleEditSheet(
                 checked = hasTextColor,
                 onCheckedChange = { hasTextColor = it },
             )
-            if (hasTextColor) {
+            AnimatedVisibility(visible = hasTextColor) {
                 TinyColorSettingItem(
                     title = stringResource(R.string.select_color),
                     colorValue = textColor,
@@ -318,59 +319,61 @@ fun HighlightRuleEditSheet(
                 checked = hasUnderline,
                 onCheckedChange = { hasUnderline = it },
             )
-            if (hasUnderline) {
-                val underlineEntries = arrayOf(
-                    stringResource(R.string.underline_solid),
-                    stringResource(R.string.underline_dashed),
-                    stringResource(R.string.underline_wave),
-                    stringResource(R.string.underline_title_bar),
-                    stringResource(R.string.underline_svg),
-                )
-                val underlineValues = arrayOf("1", "2", "3", "4", "5")
-                TinyDropdownSettingItem(
-                    title = stringResource(R.string.underline_style),
-                    selectedValue = underlineMode.toString(),
-                    displayEntries = underlineEntries,
-                    entryValues = underlineValues,
-                    onValueChange = { underlineMode = it.toIntOrNull() ?: 1 },
-                )
-
-                TinySwitchSettingItem(
-                    title = stringResource(R.string.underline_color),
-                    checked = hasUnderlineColor,
-                    onCheckedChange = { hasUnderlineColor = it },
-                )
-                if (hasUnderlineColor) {
-                    TinyColorSettingItem(
-                        title = stringResource(R.string.select_color),
-                        colorValue = underlineColor,
-                        onClick = { showUnderlineColorPicker = true },
+            AnimatedVisibility(visible = hasUnderline) {
+                Column {
+                    val underlineEntries = arrayOf(
+                        stringResource(R.string.underline_solid),
+                        stringResource(R.string.underline_dashed),
+                        stringResource(R.string.underline_wave),
+                        stringResource(R.string.underline_title_bar),
+                        stringResource(R.string.underline_svg),
+                    )
+                    val underlineValues = arrayOf("1", "2", "3", "4", "5")
+                    TinyDropdownSettingItem(
+                        title = stringResource(R.string.underline_style),
+                        selectedValue = underlineMode.toString(),
+                        displayEntries = underlineEntries,
+                        entryValues = underlineValues,
+                        onValueChange = { underlineMode = it.toIntOrNull() ?: 1 },
                     )
-                }
 
-                TinySliderSettingItem(
-                    title = stringResource(R.string.underline_width),
-                    value = underlineWidth,
-                    valueRange = 0.1f..10f,
-                    description = String.format("%.1f dp", underlineWidth),
-                    onValueChange = { underlineWidth = (it * 10).toInt() / 10f },
-                )
+                    TinySwitchSettingItem(
+                        title = stringResource(R.string.underline_color),
+                        checked = hasUnderlineColor,
+                        onCheckedChange = { hasUnderlineColor = it },
+                    )
+                    AnimatedVisibility(visible = hasUnderlineColor) {
+                        TinyColorSettingItem(
+                            title = stringResource(R.string.select_color),
+                            colorValue = underlineColor,
+                            onClick = { showUnderlineColorPicker = true },
+                        )
+                    }
 
-                TinySliderSettingItem(
-                    title = stringResource(R.string.underline_offset),
-                    value = underlineOffset,
-                    valueRange = 0f..20f,
-                    description = String.format("%.1f dp", underlineOffset),
-                    onValueChange = { underlineOffset = (it * 10).toInt() / 10f },
-                )
+                    TinySliderSettingItem(
+                        title = stringResource(R.string.underline_width),
+                        value = underlineWidth,
+                        valueRange = 0.1f..10f,
+                        description = String.format("%.1f dp", underlineWidth),
+                        onValueChange = { underlineWidth = (it * 10).toInt() / 10f },
+                    )
 
-                if (underlineMode == 5) {
-                    AppTextField(
-                        value = underlineSvgPath,
-                        onValueChange = { underlineSvgPath = it },
-                        label = stringResource(R.string.svg_path),
-                        modifier = Modifier.fillMaxWidth(),
+                    TinySliderSettingItem(
+                        title = stringResource(R.string.underline_offset),
+                        value = underlineOffset,
+                        valueRange = 0f..20f,
+                        description = String.format("%.1f dp", underlineOffset),
+                        onValueChange = { underlineOffset = (it * 10).toInt() / 10f },
                     )
+
+                    AnimatedVisibility(visible = underlineMode == 5) {
+                        AppTextField(
+                            value = underlineSvgPath,
+                            onValueChange = { underlineSvgPath = it },
+                            label = stringResource(R.string.svg_path),
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                    }
                 }
             }
 
@@ -380,7 +383,7 @@ fun HighlightRuleEditSheet(
                 checked = hasBgColor,
                 onCheckedChange = { hasBgColor = it },
             )
-            if (hasBgColor) {
+            AnimatedVisibility(visible = hasBgColor) {
                 TinyColorSettingItem(
                     title = stringResource(R.string.select_color),
                     colorValue = bgColor,
@@ -394,7 +397,7 @@ fun HighlightRuleEditSheet(
                 checked = hasBgImage,
                 onCheckedChange = { hasBgImage = it },
             )
-            if (hasBgImage) {
+            AnimatedVisibility(visible = hasBgImage) {
                 TinyClickableSettingItem(
                     title = stringResource(R.string.highlight_bg_image),
                     description = bgImage.ifBlank { null }?.let { File(it).name },
@@ -403,28 +406,30 @@ fun HighlightRuleEditSheet(
                     },
                 )
             }
-            if (hasBgImage && bgImage.isNotBlank()) {
-                val fitEntries = arrayOf(
-                    stringResource(R.string.bg_fit_tile),
-                    stringResource(R.string.bg_fit_stretch),
-                    stringResource(R.string.bg_fit_crop),
-                )
-                val fitValues = arrayOf("0", "1", "2")
-                TinyDropdownSettingItem(
-                    title = stringResource(R.string.bg_image_fit),
-                    selectedValue = bgImageFit.toString(),
-                    displayEntries = fitEntries,
-                    entryValues = fitValues,
-                    onValueChange = { bgImageFit = it.toIntOrNull() ?: 0 },
-                )
+            AnimatedVisibility(visible = hasBgImage && bgImage.isNotBlank()) {
+                Column {
+                    val fitEntries = arrayOf(
+                        stringResource(R.string.bg_fit_tile),
+                        stringResource(R.string.bg_fit_stretch),
+                        stringResource(R.string.bg_fit_crop),
+                    )
+                    val fitValues = arrayOf("0", "1", "2")
+                    TinyDropdownSettingItem(
+                        title = stringResource(R.string.bg_image_fit),
+                        selectedValue = bgImageFit.toString(),
+                        displayEntries = fitEntries,
+                        entryValues = fitValues,
+                        onValueChange = { bgImageFit = it.toIntOrNull() ?: 0 },
+                    )
 
-                TinySliderSettingItem(
-                    title = stringResource(R.string.highlight_bg_image_scale),
-                    value = bgImageScale,
-                    valueRange = 0.1f..5f,
-                    description = String.format("%.1fx", bgImageScale),
-                    onValueChange = { bgImageScale = (it * 10).toInt() / 10f },
-                )
+                    TinySliderSettingItem(
+                        title = stringResource(R.string.highlight_bg_image_scale),
+                        value = bgImageScale,
+                        valueRange = 0.1f..5f,
+                        description = String.format("%.1fx", bgImageScale),
+                        onValueChange = { bgImageScale = (it * 10).toInt() / 10f },
+                    )
+                }
             }
 
             // === Section 3: Config Binding ===
@@ -486,7 +491,7 @@ fun HighlightRuleEditSheet(
                 checked = hasFont,
                 onCheckedChange = { hasFont = it },
             )
-            if (hasFont) {
+            AnimatedVisibility(visible = hasFont) {
                 TinyClickableSettingItem(
                     title = stringResource(R.string.select_font),
                     description = fontPath.ifBlank { null }?.let { File(it).name },

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt

@@ -49,6 +49,7 @@ fun ReadStyleContent(
     readMenuCustomIcons: Map<String, String> = emptyMap(),
     bottomBarButtons: List<ReadBookButtonConfigItem> = emptyList(),
     preferences: ReadPreferences,
+    eyeProtectionEnabled: Boolean,
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
     styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
@@ -94,7 +95,7 @@ fun ReadStyleContent(
                 when (page) {
                     0 -> GlobalThemePage(
                         onToggleDayNight = onToggleDayNight,
-                        eyeProtectionEnabled = preferences.eyeProtectionEnabled,
+                        eyeProtectionEnabled = eyeProtectionEnabled,
                         onOpenBgTextConfig = onOpenBgTextConfig,
                         onOpenTextTitle = onOpenTextTitle,
                         onOpenPaddingConfig = onOpenPaddingConfig,

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt

@@ -554,8 +554,8 @@ internal fun TitleSettingsPage(
         TinySliderSettingItem(
             title = stringResource(R.string.subtitle_scale),
             value = titleSegScaling,
-            valueRange = -2f..2f,
-            steps = 39,
+            valueRange = 0f..2f,
+            steps = 19,
             stepSize = 0.1f,
             valueFormat = { "%.1f".format(it) },
             onValueChange = { value ->

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -80,6 +80,7 @@ import org.koin.androidx.compose.koinViewModel
 @Composable
 fun SearchContentRouteScreen(
     onBack: () -> Unit,
+    autoFocus: Boolean = true,
     viewModel: SearchContentViewModel = koinViewModel()
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -95,6 +96,7 @@ fun SearchContentRouteScreen(
     }
     SearchContentScreen(
         state = state,
+        autoFocus = autoFocus,
         onIntent = viewModel::onIntent,
         onBack = onBack,
     )
@@ -104,6 +106,7 @@ fun SearchContentRouteScreen(
 @Composable
 fun SearchContentScreen(
     state: SearchContentUiState,
+    autoFocus: Boolean = true,
     onIntent: (SearchContentIntent) -> Unit,
     onBack: () -> Unit,
 ) {
@@ -193,6 +196,7 @@ fun SearchContentScreen(
                 ) {
                     SearchBar(
                         query = searchQuery,
+                        autoFocus = autoFocus,
                         scrollState = listState,
                         onQueryChange = { onIntent(SearchContentIntent.UpdateQuery(it)) },
                         trailingIcon = {

+ 32 - 20
app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt

@@ -42,31 +42,43 @@ data class SearchResult(
     fun getContentSpannable(textColor: Int, accentColor: Int, bgColor: Int): SpannableStringBuilder {
         val spannable = SpannableStringBuilder(resultText)
 
-        if (query.isNotBlank()) {
-            var searchStart = 0
-            while (true) {
-                val start = resultText.indexOf(query, searchStart, ignoreCase = true)
-                if (start == -1) break
-                val end = start + query.length
+        // Apply the body color first so the more specific highlight color wins.
+        if (!AppConfig.isEInkMode) {
+            spannable.setSpan(
+                ForegroundColorSpan(textColor),
+                0,
+                spannable.length,
+                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
+            )
+        }
 
-                if (AppConfig.isEInkMode) {
-                    spannable.setSpan(UnderlineSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
-                } else {
-                    // 字体加粗
-                    spannable.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
-                    // 字体颜色
-                    spannable.setSpan(ForegroundColorSpan(accentColor), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
-                    // 背景色
-                    spannable.setSpan(BackgroundColorSpan(bgColor), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+        val ranges = if (isRegex) {
+            listOf(queryIndexInResult to matchLength)
+        } else if (query.isNotBlank()) {
+            buildList {
+                var searchStart = 0
+                while (searchStart < resultText.length) {
+                    val start = resultText.indexOf(query, searchStart, ignoreCase = true)
+                    if (start == -1) break
+                    add(start to query.length)
+                    searchStart = start + query.length.coerceAtLeast(1)
                 }
-
-                searchStart = end
             }
+        } else {
+            emptyList()
         }
 
-        // 设置整体字体颜色(正文)
-        if (!AppConfig.isEInkMode) {
-            spannable.setSpan(ForegroundColorSpan(textColor), 0, spannable.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+        ranges.forEach { (start, length) ->
+            val end = (start + length).coerceAtMost(resultText.length)
+            if (start !in 0 until end) return@forEach
+
+            if (AppConfig.isEInkMode) {
+                spannable.setSpan(UnderlineSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+            } else {
+                spannable.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+                spannable.setSpan(ForegroundColorSpan(accentColor), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+                spannable.setSpan(BackgroundColorSpan(bgColor), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+            }
         }
 
         return spannable

+ 5 - 3
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.config.readConfig
 
 import androidx.compose.runtime.Stable
+import io.legado.app.ui.book.read.EyeProtectionUiState
 
 @Stable
 data class ReadConfigUiState(
@@ -17,9 +18,7 @@ data class ReadConfigUiState(
     val textBottomJustify: Boolean = true,
     val adaptSpecialStyle: Boolean = true,
     val useZhLayout: Boolean = false,
-    val eyeProtectionEnabled: Boolean = false,
-    val eyeProtectionIntensity: Int = 50,
-    val eyeProtectionAutoNight: Boolean = false,
+    val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
     val showBrightnessView: String = "0",
     val brightnessVwPos: String = "1",
     val brightnessAuto: Boolean = true,
@@ -78,6 +77,9 @@ sealed interface ReadConfigIntent {
     data class EyeProtectionEnabledChanged(val value: Boolean) : ReadConfigIntent
     data class EyeProtectionIntensityChanged(val value: Int) : ReadConfigIntent
     data class EyeProtectionAutoNightChanged(val value: Boolean) : ReadConfigIntent
+    data class EyeProtectionScheduleChanged(val value: Boolean) : ReadConfigIntent
+    data class EyeProtectionStartTimeChanged(val value: String) : ReadConfigIntent
+    data class EyeProtectionEndTimeChanged(val value: String) : ReadConfigIntent
     data class ShowBrightnessViewChanged(val value: String) : ReadConfigIntent
     data class BrightnessVwPosChanged(val value: String) : ReadConfigIntent
     data class UseUnderlineChanged(val value: Boolean) : ReadConfigIntent

+ 10 - 4
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt

@@ -171,7 +171,7 @@ fun ReadConfigScreen(
 
                 ClickableSettingItem(
                     title = stringResource(R.string.eye_protection),
-                    option = if (settings.eyeProtectionEnabled) {
+                    option = if (state.eyeProtection.configured) {
                         stringResource(R.string.enabled)
                     } else {
                         stringResource(R.string.disabled)
@@ -414,12 +414,18 @@ fun ReadConfigScreen(
 
     EyeProtectionConfigSheet(
         show = state.activeSheet == ReadConfigSheet.EyeProtection,
-        enabled = settings.eyeProtectionEnabled,
-        intensity = settings.eyeProtectionIntensity,
-        autoNight = settings.eyeProtectionAutoNight,
+        enabled = state.eyeProtection.enabled,
+        intensity = state.eyeProtection.intensity,
+        autoNight = state.eyeProtection.autoNight,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
         onDismissRequest = { onIntent(ReadConfigIntent.DismissSheet) },
         onEnabledChange = { onIntent(ReadConfigIntent.EyeProtectionEnabledChanged(it)) },
         onIntensityChange = { onIntent(ReadConfigIntent.EyeProtectionIntensityChanged(it)) },
         onAutoNightChange = { onIntent(ReadConfigIntent.EyeProtectionAutoNightChanged(it)) },
+        onScheduleChange = { onIntent(ReadConfigIntent.EyeProtectionScheduleChanged(it)) },
+        onStartTimeChange = { onIntent(ReadConfigIntent.EyeProtectionStartTimeChanged(it)) },
+        onEndTimeChange = { onIntent(ReadConfigIntent.EyeProtectionEndTimeChanged(it)) },
     )
 }

+ 63 - 15
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt

@@ -3,7 +3,10 @@ package io.legado.app.ui.config.readConfig
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.ReadSettings
+import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.ui.book.read.EyeProtectionUiState
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -13,11 +16,15 @@ import kotlinx.coroutines.launch
 
 class ReadConfigViewModel(
     private val settingsGateway: ReadSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
     private val applyReadSetting: ApplyReadSettingUseCase,
 ) : ViewModel() {
 
     private val _uiState = MutableStateFlow(
-        settingsGateway.currentSettings.toUiState(activeSheet = null)
+        settingsGateway.currentSettings.toUiState(
+            activeSheet = null,
+            eyeProtection = themeSettingsGateway.currentSettings.toEyeProtectionUiState(),
+        )
     )
     val uiState = _uiState.asStateFlow()
 
@@ -27,7 +34,17 @@ class ReadConfigViewModel(
     init {
         viewModelScope.launch {
             settingsGateway.settings.collect { settings ->
-                _uiState.update { settings.toUiState(activeSheet = it.activeSheet) }
+                _uiState.update {
+                    settings.toUiState(
+                        activeSheet = it.activeSheet,
+                        eyeProtection = it.eyeProtection,
+                    )
+                }
+            }
+        }
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                _uiState.update { it.copy(eyeProtection = settings.toEyeProtectionUiState()) }
             }
         }
     }
@@ -36,11 +53,34 @@ class ReadConfigViewModel(
         when (intent) {
             ReadConfigIntent.OpenPageKeys -> setSheet(ReadConfigSheet.PageKeys)
             ReadConfigIntent.OpenClickActions -> setSheet(ReadConfigSheet.ClickActions)
+            ReadConfigIntent.OpenEyeProtection -> setSheet(ReadConfigSheet.EyeProtection)
             ReadConfigIntent.DismissSheet -> setSheet(null)
+            is ReadConfigIntent.EyeProtectionEnabledChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEnabled = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionIntensityChanged -> updateEyeProtection {
+                it.copy(colorTemperature = intent.value.coerceIn(0, 100))
+            }
+            is ReadConfigIntent.EyeProtectionAutoNightChanged -> updateEyeProtection {
+                it.copy(eyeProtectionAutoNight = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionScheduleChanged -> updateEyeProtection {
+                it.copy(eyeProtectionSchedule = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionStartTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionStartTime = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionEndTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEndTime = intent.value)
+            }
             else -> updateSetting(intent)
         }
     }
 
+    private fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {
+        viewModelScope.launch { themeSettingsGateway.update(transform) }
+    }
+
     private fun setSheet(sheet: ReadConfigSheet?) {
         _uiState.update { it.copy(activeSheet = sheet) }
     }
@@ -102,22 +142,32 @@ private fun ReadConfigIntent.toSettingsTransform(): (ReadSettings) -> ReadSettin
     is ReadConfigIntent.PageKeysChanged -> { settings ->
         settings.copy(prevKeys = prevKeys, nextKeys = nextKeys)
     }
-    is ReadConfigIntent.EyeProtectionEnabledChanged -> { settings ->
-        settings.copy(eyeProtectionEnabled = value)
-    }
-    is ReadConfigIntent.EyeProtectionIntensityChanged -> { settings ->
-        settings.copy(eyeProtectionIntensity = value)
-    }
-    is ReadConfigIntent.EyeProtectionAutoNightChanged -> { settings ->
-        settings.copy(eyeProtectionAutoNight = value)
-    }
+    is ReadConfigIntent.EyeProtectionEnabledChanged,
+    is ReadConfigIntent.EyeProtectionIntensityChanged,
+    is ReadConfigIntent.EyeProtectionAutoNightChanged,
+    is ReadConfigIntent.EyeProtectionScheduleChanged,
+    is ReadConfigIntent.EyeProtectionStartTimeChanged,
+    is ReadConfigIntent.EyeProtectionEndTimeChanged ->
+        error("Eye protection intents update ThemeSettings")
     ReadConfigIntent.OpenPageKeys,
     ReadConfigIntent.OpenClickActions,
     ReadConfigIntent.OpenEyeProtection,
     ReadConfigIntent.DismissSheet -> error("Sheet intents do not update settings")
 }
 
-private fun ReadSettings.toUiState(activeSheet: ReadConfigSheet?): ReadConfigUiState =
+private fun ThemeSettings.toEyeProtectionUiState(): EyeProtectionUiState = EyeProtectionUiState(
+    enabled = eyeProtectionEnabled,
+    intensity = colorTemperature,
+    autoNight = eyeProtectionAutoNight,
+    schedule = eyeProtectionSchedule,
+    startTime = eyeProtectionStartTime,
+    endTime = eyeProtectionEndTime,
+)
+
+private fun ReadSettings.toUiState(
+    activeSheet: ReadConfigSheet?,
+    eyeProtection: EyeProtectionUiState,
+): ReadConfigUiState =
     ReadConfigUiState(
         screenOrientation = screenOrientation,
         keepLight = keepLight,
@@ -132,9 +182,7 @@ private fun ReadSettings.toUiState(activeSheet: ReadConfigSheet?): ReadConfigUiS
         textBottomJustify = textBottomJustify,
         adaptSpecialStyle = adaptSpecialStyle,
         useZhLayout = useZhLayout,
-        eyeProtectionEnabled = eyeProtectionEnabled,
-        eyeProtectionIntensity = eyeProtectionIntensity,
-        eyeProtectionAutoNight = eyeProtectionAutoNight,
+        eyeProtection = eyeProtection,
         showBrightnessView = showBrightnessView,
         brightnessVwPos = brightnessVwPos,
         brightnessAuto = brightnessAuto,

+ 4 - 4
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt

@@ -26,6 +26,10 @@ sealed interface ThemeConfigSheet {
 
 sealed interface ThemeConfigDialog {
     data object ResetDefaults : ThemeConfigDialog
+    data class TimePicker(
+        val field: ThemeTimeField,
+        val currentValue: String,
+    ) : ThemeConfigDialog
 }
 
 enum class ThemeTimeField {
@@ -88,9 +92,5 @@ sealed interface ThemeConfigEffect {
     data object OpenFontFolder : ThemeConfigEffect
     data class OpenNavigationIcon(val destination: String) : ThemeConfigEffect
     data class OpenBackgroundImage(val dark: Boolean) : ThemeConfigEffect
-    data class OpenTimePicker(
-        val field: ThemeTimeField,
-        val currentValue: String,
-    ) : ThemeConfigEffect
     data class ShowToast(val stringRes: Int) : ThemeConfigEffect
 }

+ 0 - 3
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigRouteScreen.kt

@@ -89,9 +89,6 @@ fun ThemeConfigRouteScreen(
                     backgroundImageLauncher.launch("image/*")
                 }
                 is ThemeConfigEffect.ShowToast -> context.toastOnUi(effect.stringRes)
-                is ThemeConfigEffect.OpenTimePicker -> {
-                    // Time picker intent handled by ThemeConfigScreen internally
-                }
             }
         }
     }

+ 30 - 1
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
 import androidx.constraintlayout.compose.ConstraintLayout
 import io.legado.app.R
 import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -73,6 +74,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.dialog.TimePickerDialog
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
@@ -443,7 +445,16 @@ fun ThemeConfigScreen(
                         }
                     )
 
-                    AnimatedVisibility(visible = theme.eyeProtectionEnabled) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.eye_protection_auto_night),
+                        description = stringResource(R.string.eye_protection_auto_night_summary),
+                        checked = theme.eyeProtectionAutoNight,
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(eyeProtectionAutoNight = value) }
+                        }
+                    )
+
+                    AnimatedVisibility(visible = theme.isEyeProtectionConfigured) {
                         Column {
                             SliderSettingItem(
                                 title = stringResource(R.string.color_temperature),
@@ -840,6 +851,24 @@ fun ThemeConfigScreen(
         onDismiss = { onIntent(ThemeConfigIntent.DismissDialog) },
     )
 
+    val timePickerDialog = state.activeDialog as? ThemeConfigDialog.TimePicker
+    if (timePickerDialog != null) {
+        TimePickerDialog(
+            title = stringResource(
+                when (timePickerDialog.field) {
+                    ThemeTimeField.EyeProtectionStart -> R.string.eye_protection_start_time
+                    ThemeTimeField.EyeProtectionEnd -> R.string.eye_protection_end_time
+                }
+            ),
+            currentValue = timePickerDialog.currentValue,
+            onDismissRequest = { onIntent(ThemeConfigIntent.DismissDialog) },
+            onConfirm = { value ->
+                onIntent(ThemeConfigIntent.SetTime(timePickerDialog.field, value))
+                onIntent(ThemeConfigIntent.DismissDialog)
+            },
+        )
+    }
+
 
     BackgroundImageManageSheet(
         isDarkTheme = (state.activeSheet as? ThemeConfigSheet.Background)?.dark,

+ 79 - 26
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -6,10 +6,12 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.model.settings.AppShellSettings
+import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.ui.main.MainDestination
 import io.legado.app.utils.FileDoc
@@ -21,7 +23,12 @@ import io.legado.app.utils.openInputStream
 import java.io.File
 import java.io.FileOutputStream
 import java.util.UUID
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -32,6 +39,7 @@ import splitties.init.appCtx
 
 class ThemeConfigViewModel(
     private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val coverSettingsGateway: CoverSettingsGateway,
     private val readSettingsGateway: ReadSettingsGateway,
     private val themeSettingsGateway: ThemeSettingsGateway,
     private val labSettingsGateway: LabSettingsGateway,
@@ -48,6 +56,8 @@ class ThemeConfigViewModel(
     private val _effects = MutableSharedFlow<ThemeConfigEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
+    private var fontJob: Job? = null
+
     init {
         viewModelScope.launch {
             appShellSettingsGateway.settings.collect { settings ->
@@ -142,14 +152,19 @@ class ThemeConfigViewModel(
             is ThemeConfigIntent.SelectBackground -> setBackground(intent.uri, intent.dark)
             is ThemeConfigIntent.RemoveBackground -> removeBackground(intent.dark)
             is ThemeConfigIntent.SelectAppFont -> setAppFont(intent.file)
-            ThemeConfigIntent.ClearAppFont -> updateTheme { it.copy(appFontPath = null) }
+            ThemeConfigIntent.ClearAppFont -> clearAppFont()
             is ThemeConfigIntent.SetFontFolder -> viewModelScope.launch {
                 readSettingsGateway.update { it.copy(fontFolder = intent.path) }
             }
             ThemeConfigIntent.RequestFontFolder -> _effects.tryEmit(ThemeConfigEffect.OpenFontFolder)
-            is ThemeConfigIntent.RequestTimePicker -> _effects.tryEmit(
-                ThemeConfigEffect.OpenTimePicker(intent.field, intent.currentValue)
-            )
+            is ThemeConfigIntent.RequestTimePicker -> _uiState.update {
+                it.copy(
+                    activeDialog = ThemeConfigDialog.TimePicker(
+                        field = intent.field,
+                        currentValue = intent.currentValue,
+                    )
+                )
+            }
             is ThemeConfigIntent.SetTime -> setTime(intent.field, intent.value)
             ThemeConfigIntent.DismissRefactorTip -> updateTheme {
                 it.copy(showRefactorTip = false)
@@ -174,6 +189,7 @@ class ThemeConfigViewModel(
     private fun resetDefaults() {
         viewModelScope.launch(Dispatchers.IO) {
             val defaultShell = AppShellSettings()
+            val defaultCover = CoverSettings()
             val defaultTheme = ThemeSettings()
             val currentTheme = _uiState.value.theme
 
@@ -192,6 +208,7 @@ class ThemeConfigViewModel(
             }
 
             appShellSettingsGateway.update { defaultShell }
+            coverSettingsGateway.update { defaultCover }
             themeSettingsGateway.update { defaultTheme }
             _effects.tryEmit(ThemeConfigEffect.ApplyDayNight)
             _effects.tryEmit(ThemeConfigEffect.NotifyMain)
@@ -368,32 +385,68 @@ class ThemeConfigViewModel(
     }
 
     private fun setAppFont(fileDoc: FileDoc) {
-        viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                val extension = fileDoc.name.substringAfterLast('.', "ttf")
-                    .lowercase()
-                    .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
-                    ?: "ttf"
-                val fontDir = File(appCtx.filesDir, "fonts").apply { mkdirs() }
-                val target = File(fontDir, "app_font_${UUID.randomUUID()}.$extension")
-                val temp = File(fontDir, "${target.name}.tmp")
+        launchFontJob {
+            val extension = fileDoc.name.substringAfterLast('.', "ttf")
+                .lowercase()
+                .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+                ?: "ttf"
+            val fontDir = appFontDir()
+            val temp = File(fontDir, "app_font_${UUID.randomUUID()}.tmp")
+            val target = try {
                 fileDoc.openInputStream().getOrThrow().use { input ->
                     FileOutputStream(temp).use(input::copyTo)
                 }
-                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")
+                // 以内容摘要命名:同一字体无论导入多少次都指向同一路径,
+                // 既不会留下重复副本,字体缓存也能按路径命中。
+                val digest = temp.inputStream().use(MD5Utils::md5Encode)
+                File(fontDir, "app_font_$digest.$extension").also { target ->
+                    if (target.isFile) {
+                        temp.delete()
+                    } else if (!temp.renameTo(target)) {
+                        temp.copyTo(target, overwrite = true)
+                        temp.delete()
                     }
-                    ?.delete()
-            }.onFailure(Throwable::printStackTrace)
+                }
+            } catch (e: Throwable) {
+                temp.delete()
+                throw e
+            }
+            // 复制期间可能已被新的选择或清除取代,此时不能写回路径。
+            ensureActive()
+            themeSettingsGateway.update { it.copy(appFontPath = target.absolutePath) }
+            pruneCopiedFonts(keep = target)
+        }
+    }
+
+    private fun clearAppFont() {
+        launchFontJob {
+            themeSettingsGateway.update { it.copy(appFontPath = null) }
+            pruneCopiedFonts()
+        }
+    }
+
+    /**
+     * 字体的选择与清除串行执行:新任务先取消并等待上一个结束,
+     * 避免未完成的复制晚于清除写回 appFontPath。
+     */
+    private fun launchFontJob(block: suspend CoroutineScope.() -> Unit) {
+        val previous = fontJob
+        fontJob = viewModelScope.launch(Dispatchers.IO) {
+            previous?.cancelAndJoin()
+            runCatching { block() }
+                .onFailure { if (it !is CancellationException) it.printStackTrace() }
+        }
+    }
+
+    private fun appFontDir() = File(appCtx.filesDir, "fonts").apply { mkdirs() }
+
+    /**
+     * 清理私有目录里已经用不到的字体副本,只认本应用复制的 app_font 前缀,
+     * 不碰主题包导入的 theme_ 资源。
+     */
+    private fun pruneCopiedFonts(keep: File? = null) {
+        appFontDir().listFiles()?.forEach { file ->
+            if (file.name.startsWith("app_font") && file != keep) file.delete()
         }
     }
 }

+ 4 - 2
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -443,12 +443,13 @@ fun MainActivity.mainEntryProvider(
             host = controller,
             controller = controller,
             onEffectsReady = { effectsReady.complete(Unit) },
-            onOpenSearch = { word, bookUrl ->
+            onOpenSearch = { word, bookUrl, autoFocus ->
                 onNavigateToRoute(
                     MainRouteSearchContent(
                         bookUrl = bookUrl,
                         searchWord = word,
-                        searchResultIndex = readBookViewModel.uiState.value.searchResultIndex
+                        searchResultIndex = readBookViewModel.uiState.value.searchResultIndex,
+                        autoFocus = autoFocus,
                     )
                 )
             },
@@ -521,6 +522,7 @@ fun MainActivity.mainEntryProvider(
         )
         SearchContentRouteScreen(
             viewModel = viewModel,
+            autoFocus = route.autoFocus,
             onBack = { onNavigateBack() },
         )
     }

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

@@ -177,6 +177,7 @@ data class MainRouteSearchContent(
     val bookUrl: String,
     val searchWord: String? = null,
     val searchResultIndex: Int = 0,
+    val autoFocus: Boolean = true,
 ) : MainRoute
 
 @Serializable

+ 0 - 10
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -223,20 +223,11 @@ fun MainScreen(
         orientation = Orientation.Horizontal,
     )
     var bookshelfScrollToTopRequest by remember { mutableLongStateOf(0L) }
-    var homeOverflowMenuRequest by remember { mutableLongStateOf(0L) }
     fun requestBookshelfScrollToTop() {
         bookshelfScrollToTopRequest++
     }
 
     fun handleMainDestinationClick(index: Int, destination: MainDestination) {
-        if (
-            destination == MainDestination.Home &&
-            pagerState.currentPage == index &&
-            pagerState.targetPage == index
-        ) {
-            homeOverflowMenuRequest++
-            return
-        }
         if (
             destination == MainDestination.Bookshelf &&
             pagerState.currentPage == index &&
@@ -524,7 +515,6 @@ fun MainScreen(
                         CompositionLocalProvider(LocalLifecycleOwner provides pageLifecycleOwner) {
                             when (destination) {
                             MainDestination.Home -> HomeRouteScreen(
-                                showOverflowMenuRequest = homeOverflowMenuRequest,
                                 onOpenBook = { book ->
                                     context.startActivityForBook(book)
                                 },

+ 5 - 1
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material.icons.filled.Update
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -60,6 +61,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookshelfCover
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.toTimeAgo
 import kotlinx.collections.immutable.ImmutableList
 
@@ -755,7 +757,9 @@ fun BookItem(
         columnContent = if (layoutMode == 0 && !isCompact && settings.showBookIntro) {
             {
                 val kindList = bookUi.displayTags
-                val intro = book.intro?.takeIf { it.isNotBlank() }
+                val intro = remember(book.intro) {
+                    HtmlFormatter.formatDisplayText(book.intro).takeIf { it.isNotBlank() }
+                }
                 if (settings.bookshelfShowTag && kindList.isNotEmpty()) {
                     Row(
                         modifier = Modifier

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -611,6 +611,9 @@ class BookshelfViewModel(
         SharingStarted.WhileSubscribed(5000),
         BookshelfUiState(
             settings = initialSettings,
+            selectedGroupId = initialSettings.saveTabPosition,
+            isInFolderRoot = initialSettings.bookGroupStyle == 2,
+            bookGroupStyle = initialSettings.bookGroupStyle,
             useRaisedBottomInset = initialAppShellSettings.useFloatingBottomBar || initialThemeSettings.enableBlur,
             enableCustomTagColors = initialThemeSettings.enableCustomTagColors,
             customTagColors = parseTagColors(initialThemeSettings.customTagColorsJson),

+ 0 - 9
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt

@@ -135,7 +135,6 @@ import kotlin.math.roundToInt
 
 @Composable
 fun HomeRouteScreen(
-    showOverflowMenuRequest: Long = 0L,
     onOpenBook: (Book) -> Unit,
     onNavigateToBookInfo: (
         name: String?,
@@ -334,7 +333,6 @@ fun HomeRouteScreen(
     }
 
     HomeScreen(
-        showOverflowMenuRequest = showOverflowMenuRequest,
         state = state,
         homepageState = homepageState,
         homepageFeedActions = feedActions,
@@ -381,7 +379,6 @@ fun HomeRouteScreen(
 )
 @Composable
 fun HomeScreen(
-    showOverflowMenuRequest: Long = 0L,
     state: HomeUiState,
     homepageState: HomepageUiState,
     homepageFeedActions: HomepageFeedActions,
@@ -413,12 +410,6 @@ fun HomeScreen(
         derivedStateOf { selectedSets.getOrNull(pagerState.currentPage)?.sourceName }
     }
 
-    LaunchedEffect(showOverflowMenuRequest) {
-        if (showOverflowMenuRequest > 0L) {
-            showPageMenu = true
-        }
-    }
-
     LaunchedEffect(pagerState) {
         snapshotFlow { pagerState.currentPage }
             .distinctUntilChanged()

+ 2 - 3
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -71,9 +71,9 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.applyDayNight
 import io.legado.app.utils.keepScreenOn
 import io.legado.app.utils.openUrl
-import io.legado.app.utils.setDarkeningAllowed
 import io.legado.app.utils.share
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
@@ -203,8 +203,7 @@ fun RssReadRouteScreen(
 
     LaunchedEffect(isNight, webView) {
         val currentWebView = webView ?: return@LaunchedEffect
-        currentWebView.settings.setDarkeningAllowed(isNight)
-        currentWebView.postInvalidate()
+        currentWebView.applyDayNight(isNight)
     }
 
     Box(modifier = Modifier.fillMaxSize()) {

+ 2 - 0
app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt

@@ -35,6 +35,7 @@ import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.model.Download
 import io.legado.app.ui.association.OnLineImportActivity
+import io.legado.app.utils.applyDayNight
 import io.legado.app.utils.isTrue
 import io.legado.app.utils.longSnackbar
 import io.legado.app.utils.openUrl
@@ -246,6 +247,7 @@ internal fun configureRssReadWebView(
 
         override fun onPageFinished(view: WebView, url: String?) {
             super.onPageFinished(view, url)
+            view.applyDayNight(AppConfig.isNightTheme)
             view.title?.let { webTitle ->
                 if (
                     webTitle != url &&

+ 18 - 12
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -12,8 +12,11 @@ import androidx.compose.material3.Shapes
 import androidx.compose.material3.Typography
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.produceState
+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.graphics.Color
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.platform.LocalContext
@@ -29,20 +32,23 @@ import top.yukonga.miuix.kmp.theme.defaultTextStyles
 @Composable
 fun rememberCustomFont(fontPath: String?): FontFamily? {
     val context = LocalContext.current.applicationContext
-    val cachedFont = remember(fontPath) {
-        fontPath?.let { synchronized(customFontCache) { customFontCache.get(it) } }
+    val path = fontPath?.takeIf(String::isNotBlank)
+    val cachedFont = remember(path) {
+        path?.let { synchronized(customFontCache) { customFontCache.get(it) } }
     }
-    return produceState(cachedFont, fontPath, context) {
-        if (value == null) {
-            value = fontPath?.takeIf(String::isNotBlank)?.let { path ->
-                withContext(Dispatchers.IO) {
-                    loadCustomFont(context, path)?.also { fontFamily ->
-                        synchronized(customFontCache) { customFontCache.put(path, fontFamily) }
-                    }
-                }
+    // 加载结果跨 path 保留:切换字体时旧字体一直用到新字体就位,
+    // 中途不回落默认字体。
+    var loadedFont by remember(context) { mutableStateOf<FontFamily?>(null) }
+    LaunchedEffect(path, context) {
+        if (path == null || cachedFont != null) return@LaunchedEffect
+        loadedFont = withContext(Dispatchers.IO) {
+            loadCustomFont(context, path)?.also {
+                synchronized(customFontCache) { customFontCache.put(path, it) }
             }
         }
-    }.value
+    }
+    // path 为空即"清除",必须当帧生效,不能受 loadedFont 影响。
+    return if (path == null) null else cachedFont ?: loadedFont
 }
 
 private val customFontCache = LruCache<String, FontFamily>(4)

+ 69 - 0
app/src/main/java/io/legado/app/ui/widget/components/dialog/TimePickerDialog.kt

@@ -0,0 +1,69 @@
+package io.legado.app.ui.widget.components.dialog
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.rememberTimePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.legado.app.R
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import java.util.Locale
+
+internal fun formatTimeValue(hour: Int, minute: Int): String =
+    String.format(Locale.ROOT, "%02d:%02d", hour, minute)
+
+internal fun parseTimeNumber(value: String): Int? {
+    if (value.isEmpty()) return null
+    val normalized = buildString(value.length) {
+        value.forEach { char ->
+            val digit = Character.digit(char, 10)
+            if (digit < 0) return null
+            append(('0'.code + digit).toChar())
+        }
+    }
+    return normalized.toIntOrNull()
+}
+
+/**
+ * 24 小时制时间选择对话框。[currentValue] 与回调值均为 `HH:mm`,
+ * 解析失败时回落到 00:00。
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TimePickerDialog(
+    title: String,
+    currentValue: String,
+    onDismissRequest: () -> Unit,
+    onConfirm: (String) -> Unit,
+) {
+    val timePickerState = rememberTimePickerState(
+        initialHour = parseTimeNumber(currentValue.substringBefore(':'))
+            ?.coerceIn(0, 23) ?: 0,
+        initialMinute = parseTimeNumber(currentValue.substringAfter(':', ""))
+            ?.coerceIn(0, 59) ?: 0,
+        is24Hour = true,
+    )
+    AppAlertDialog(
+        show = true,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        content = {
+            Box(
+                modifier = Modifier.fillMaxWidth(),
+                contentAlignment = Alignment.Center
+            ) {
+                TimePicker(state = timePickerState)
+            }
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            onConfirm(formatTimeValue(timePickerState.hour, timePickerState.minute))
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismissRequest,
+    )
+}

+ 3 - 24
app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt

@@ -3,7 +3,6 @@ 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
@@ -72,7 +71,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.applyDayNight
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -527,27 +526,7 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view
     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()
+        currentWebView.applyDayNight(isDark)
     }
 
     private fun buildSpliceHtml(originalHtml: String): String {
@@ -786,7 +765,7 @@ 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) }
+            applyWebViewTheme(appliedDarkTheme ?: AppConfig.isNightTheme, force = true)
         }
 
         private fun shouldOverrideUrlLoading(url: Uri): Boolean {

+ 12 - 0
app/src/main/java/io/legado/app/utils/HtmlFormatter.kt

@@ -1,6 +1,7 @@
 package io.legado.app.utils
 
 import io.legado.app.model.analyzeRule.AnalyzeUrl
+import org.jsoup.Jsoup
 import java.net.URL
 import java.util.regex.Pattern
 
@@ -34,6 +35,17 @@ object HtmlFormatter {
             .replace(lastRegex, "")
     }
 
+    /**
+     * Formats untrusted HTML for plain-text UI surfaces.
+     * Script and style elements must be removed with their contents before stripping tags.
+     */
+    fun formatDisplayText(html: String?): String {
+        if (html.isNullOrBlank()) return ""
+        val body = Jsoup.parseBodyFragment(html).body()
+        body.select("script, style, noscript").remove()
+        return format(body.html())
+    }
+
     fun formatKeepImg(html: String?, redirectUrl: URL? = null): String {
         html ?: return ""
         val keepImgHtml = format(html, notImgHtmlRegex)

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

@@ -66,7 +66,7 @@ fun CharSequence.replace(
                     block.resumeWithException(e)
                 }
             }
-            handler.postDelayed(timeout) {
+            val timeoutRunnable = handler.postDelayed(timeout) {
                 if (coroutine.isActive) {
                     val timeoutMsg =
                         "替换超时,3秒后还未结束将重启应用\n替换规则$regex\n替换内容:$charSequence"
@@ -81,6 +81,11 @@ fun CharSequence.replace(
                     }
                 }
             }
+            // 正则结束后立即撤掉看门狗,否则它会在主线程消息队列里存活整个 timeout 窗口,
+            // 并钉住整段正文拷贝;规则数量多时会在 3 秒内堆积成百上千份拷贝导致 OOM。
+            coroutine.invokeOnCompletion {
+                handler.removeCallbacks(timeoutRunnable)
+            }
         }
     }
 }

+ 36 - 11
app/src/main/java/io/legado/app/utils/WebSettingsExtensions.kt

@@ -1,23 +1,20 @@
 package io.legado.app.utils
 
-import android.annotation.SuppressLint
-import android.os.Build
+import android.content.res.Configuration
 import android.webkit.WebSettings
+import android.webkit.WebView
 import androidx.webkit.WebSettingsCompat
 import androidx.webkit.WebViewFeature
 
 /**
- * 设置是否夜间模式
+ * 设置是否夜间模式。
+ * 能否算法反色取决于 WebView 内核版本而非系统版本,用特性检测而不是 SDK_INT 判断;
+ * 内核不支持时回落到旧的 forceDark 组合。
  */
-@SuppressLint("RequiresFeature")
 fun WebSettings.setDarkeningAllowed(allow: Boolean) {
-    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-        kotlin.runCatching {
-            WebSettingsCompat.setAlgorithmicDarkeningAllowed(this, allow)
-            return
-        }.onFailure {
-            it.printOnDebug()
-        }
+    if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
+        WebSettingsCompat.setAlgorithmicDarkeningAllowed(this, allow)
+        return
     }
     if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) {
         @Suppress("DEPRECATION")
@@ -34,3 +31,31 @@ fun WebSettings.setDarkeningAllowed(allow: Boolean) {
         )
     }
 }
+
+/**
+ * 把当前深浅色下发给 WebView。
+ *
+ * 页面侧的 prefers-color-scheme 取自 WebView 上下文主题的 android:isLightTheme,
+ * 也就是 Activity 资源的 uiMode,由 AppCompat 的夜间模式决定,不归这里管;
+ * 这里只负责重绘 WebView 自身、开关兜底的算法反色,并通知页面脚本。
+ *
+ * 不要在这里写 documentElement 的 color-scheme:页面一旦显式声明配色方案,
+ * Chromium 就认为它自带深浅色适配,从此不再对它做算法反色,切回深色时页面会一直停在浅色。
+ */
+fun WebView.applyDayNight(isDark: Boolean) {
+    val configuration = Configuration(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
+        }
+    }
+    dispatchConfigurationChanged(configuration)
+    settings.setDarkeningAllowed(isDark)
+    evaluateJavascript(
+        "window.dispatchEvent(new CustomEvent('legado-theme-change'," +
+            " { detail: { dark: $isDark } }));",
+        null,
+    )
+    postInvalidate()
+}

+ 0 - 287
app/src/main/res/layout/dialog_tip_config.xml

@@ -1,287 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical"
-        android:paddingHorizontal="16dp">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:gravity="center"
-            android:paddingBottom="8dp"
-            android:paddingHorizontal="16dp">
-
-            <com.google.android.material.bottomsheet.BottomSheetDragHandleView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:paddingVertical="8dp"
-                android:minHeight="0dp"
-                android:layout_gravity="center_horizontal" />
-
-            <TextView
-                android:id="@+id/bottom_sheet_title"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginVertical="8dp"
-                android:text="@string/read_config"
-                android:textSize="18sp"
-                android:textStyle="bold" />
-
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical"
-            android:orientation="horizontal">
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/body_title"
-                android:textSize="16sp"
-                android:textStyle="bold" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <com.google.android.material.chip.ChipGroup
-                android:id="@+id/rg_title_mode"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginVertical="8dp"
-                app:selectionRequired="true"
-                app:singleLine="true"
-                app:singleSelection="true">
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_title_mode1"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:text="@string/title_left" />
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_title_mode2"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:text="@string/title_center" />
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_title_mode3"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:text="@string/title_hide" />
-
-            </com.google.android.material.chip.ChipGroup>
-
-        </LinearLayout>
-
-
-        <com.google.android.material.button.MaterialButtonGroup
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="end">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btnSelectTitleFont"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                app:icon="@drawable/ic_title_font"
-                android:text="@string/text_font"
-                android:minWidth="0dp"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"/>
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/textFontWeightConverter"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/title_font_size"
-                android:minWidth="0dp" />
-
-            <io.legado.app.ui.widget.AccentColorButton
-                android:id="@+id/abtnTitleColor"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"
-                android:text="@string/title_color" />
-
-        </com.google.android.material.button.MaterialButtonGroup>
-
-        <com.google.android.material.button.MaterialButtonGroup
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="end">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_title_segType"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/split_title"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"/>
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_title_seg_config"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/split_title_mode"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"/>
-
-        </com.google.android.material.button.MaterialButtonGroup>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_seg_scaling"
-                android:layout_width="108dp"
-                android:layout_height="wrap_content"
-                app:max="20"
-                app:min="-20"
-                app:title="@string/subtitle_scale" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"/>
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_size"
-                android:layout_width="108dp"
-                android:layout_height="wrap_content"
-                app:max="20"
-                app:title="@string/title_font_size" />
-
-        </LinearLayout>
-
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_line_spacing_sub"
-                android:layout_width="108dp"
-                android:layout_height="wrap_content"
-                app:max="30"
-                app:min="-30"
-                app:title="@string/subtitle_margin" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_line_spacing_extra"
-                android:layout_width="108dp"
-                android:layout_height="wrap_content"
-                app:max="20"
-                app:title="@string/heading_spacing" />
-
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_top"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="200"
-                app:title="@string/title_margin_top" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"/>
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_title_bottom"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="200"
-                app:title="@string/title_margin_bottom" />
-
-        </LinearLayout>
-
-        <TextView
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="8dp"
-            android:text="@string/tool_bar_style"
-            android:textSize="16sp"
-            android:textStyle="bold"/>
-
-        <com.google.android.material.chip.ChipGroup
-            android:id="@+id/bottom_mode"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="end"
-            android:layout_marginVertical="8dp"
-            app:singleLine="true"
-            app:singleSelection="true"
-            app:selectionRequired="true">
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/bottom_mode1"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:text="@string/flow_sys" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/bottom_mode2"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:text="@string/follow_read_background" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/bottom_mode3"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:text="@string/custom" />
-
-        </com.google.android.material.chip.ChipGroup>
-
-        <com.google.android.material.button.MaterialButtonGroup
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="end">
-
-            <io.legado.app.ui.widget.AccentColorButton
-                android:id="@+id/abtnBackgroundColor"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-                android:text="@string/background_color" />
-
-            <io.legado.app.ui.widget.AccentColorButton
-                android:id="@+id/abtnAccentColor"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-                android:text="@string/accent" />
-
-        </com.google.android.material.button.MaterialButtonGroup>
-
-    </LinearLayout>
-
-</androidx.core.widget.NestedScrollView>

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

@@ -1648,7 +1648,7 @@
     <string name="compact_list">紧凑列表</string>
     <string name="author_read">%1$s • %2$d章未读</string>
     <string name="menu_alpha_sum">当前 %d%%</string>
-    <string name="menu_alpha">菜单透明度</string>
+    <string name="menu_alpha">菜单不透明度</string>
     <string name="change_source_option_title">请选择操作</string>
     <string name="replace_current_book">替换本书</string>
     <string name="add_as_new_book">新增书籍</string>
@@ -2296,7 +2296,7 @@
     <string name="show_tags">显示标签</string>
     <string name="custom_tag_colors">自定义标签颜色</string>
     <string name="manage_tag_colors">管理标签颜色</string>
-    <string name="search_filter_first">搜索按钮优先打开筛选栏</string>
+    <string name="search_filter_first">搜索按钮优先打开搜索页面</string>
     <string name="import_from_network">网络导入</string>
     <string name="input_url_or_json_hint">请输入 URL 或 JSON</string>
     <string name="history_label">历史记录:</string>

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

@@ -1868,7 +1868,7 @@
     <string name="manual_input">手動輸入</string>
     <string name="match_pattern">匹配模式</string>
     <string name="material_version">顏色規格</string>
-    <string name="menu_alpha">選單透明度</string>
+    <string name="menu_alpha">選單不透明度</string>
     <string name="menu_alpha_sum">目前 %d%%</string>
     <string name="minify_json">壓縮</string>
     <string name="month_format">%1$d</string>
@@ -2054,7 +2054,7 @@
     <string name="search_empty">暫無內容</string>
     <string name="search_empty_scope_disable_precision">%1$s 分組搜索無結果。是否關閉精準搜索?</string>
     <string name="search_empty_scope_switch_all">%1$s 分組搜索無結果。是否切換到全部分組?</string>
-    <string name="search_filter_first">搜索按鈕優先打開篩選</string>
+    <string name="search_filter_first">搜索按鈕優先打開搜索頁面</string>
     <string name="search_has_more">上拉或點擊按鈕載入更多</string>
     <string name="search_layout_list">列表佈局</string>
     <string name="search_layout_source_grouped">按源分組</string>

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

@@ -1798,7 +1798,7 @@
     <string name="compact_list">精簡列表</string>
     <string name="author_read">%1$s • %2$d 未讀</string>
     <string name="menu_alpha_sum">目前 %d%%</string>
-    <string name="menu_alpha">選單透明度</string>
+    <string name="menu_alpha">選單不透明度</string>
     <string name="change_source_option_title">選擇操作</string>
     <string name="replace_current_book">取代此書</string>
     <string name="add_as_new_book">新增為新書</string>
@@ -2230,7 +2230,7 @@
     <string name="show_tags">顯示標籤</string>
     <string name="custom_tag_colors">自訂標籤顏色</string>
     <string name="manage_tag_colors">管理標籤顏色</string>
-    <string name="search_filter_first">搜尋按鈕先開啟篩選</string>
+    <string name="search_filter_first">搜尋按鈕優先開啟搜尋頁面</string>
     <string name="import_from_network">從網路匯入</string>
     <string name="input_url_or_json_hint">輸入 URL 或 JSON</string>
     <string name="history_label">歷史記錄:</string>

+ 2 - 2
app/src/main/res/values/strings.xml

@@ -1708,7 +1708,7 @@
     <string name="compact_list">Compact List</string>
     <string name="author_read">%1$s • %2$d Not read</string>
     <string name="menu_alpha_sum">Current %d%%</string>
-    <string name="menu_alpha">Menu Transparency</string>
+    <string name="menu_alpha">Menu opacity</string>
     <string name="change_source_option_title">Choose an Action</string>
     <string name="replace_current_book">Replace This Book</string>
     <string name="add_as_new_book">Add as New Book</string>
@@ -2327,7 +2327,7 @@
     <string name="show_tags">Show Tags</string>
     <string name="custom_tag_colors">Custom Tag Colors</string>
     <string name="manage_tag_colors">Manage Tag Colors</string>
-    <string name="search_filter_first">Search button opens filter first</string>
+    <string name="search_filter_first">Search button opens the search page directly</string>
     <string name="import_from_network">Import from Network</string>
     <string name="input_url_or_json_hint">Enter URL or JSON</string>
     <string name="history_label">History:</string>

+ 48 - 0
app/src/test/java/io/legado/app/data/repository/BookGroupMutationRepositoryTest.kt

@@ -101,6 +101,54 @@ class BookGroupMutationRepositoryTest {
         assertEquals(group.groupId, database.bookDao.getBook(book.bookUrl)?.group)
     }
 
+    @Test
+    fun `删除分组时同时删除匹配规则和书籍分组位`() = runBlocking {
+        val group = BookGroup(groupId = 1L, groupName = "Fantasy")
+        val rule = TagGroupRule(id = 1L, groupName = group.groupName, pattern = "fantasy")
+        val book = Book(
+            bookUrl = "book-1",
+            name = "Book",
+            author = "Author",
+            kind = "fantasy",
+            group = group.groupId,
+        )
+        database.bookGroupDao.insert(group)
+        database.tagGroupRuleDao.insert(rule)
+        database.bookDao.insert(book)
+
+        repository.deleteGroup(group.groupId)
+
+        assertTrue(database.bookGroupDao.all.isEmpty())
+        assertTrue(database.tagGroupRuleDao.getAll().isEmpty())
+        assertEquals(0L, database.bookDao.getBook(book.bookUrl)?.group)
+    }
+
+    @Test
+    fun `标签规则只添加新匹配分组而不清除旧分组`() = runBlocking {
+        val fantasy = BookGroup(groupId = 1L, groupName = "Fantasy")
+        val adventure = BookGroup(groupId = 2L, groupName = "Adventure")
+        val book = Book(
+            bookUrl = "book-1",
+            name = "Book",
+            author = "Author",
+            kind = "adventure",
+            group = fantasy.groupId,
+        )
+        database.bookGroupDao.insert(fantasy, adventure)
+        database.tagGroupRuleDao.insert(
+            TagGroupRule(id = 1L, groupName = fantasy.groupName, pattern = "fantasy"),
+            TagGroupRule(id = 2L, groupName = adventure.groupName, pattern = "adventure"),
+        )
+        database.bookDao.insert(book)
+
+        TagGroupRuleApplier(database).applyInCurrentTransaction()
+
+        assertEquals(
+            fantasy.groupId or adventure.groupId,
+            database.bookDao.getBook(book.bookUrl)?.group,
+        )
+    }
+
     @Test
     fun `新增带规则分组后立即更新匹配书籍`() = runBlocking {
         val book = Book(

+ 3 - 10
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -13,16 +13,16 @@ import org.junit.Test
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化边界固定为显式声明的 48 键`() {
+    fun `gateway 持久化边界固定为显式声明的 45 键`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(48, actualKeys.size)
+        assertEquals(45, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
     @Test
-    fun `阅读设置 gateway 48 键写读映射逐字段对应`() {
+    fun `阅读设置 gateway 45 键写读映射逐字段对应`() {
         val repository = ReadSettingsRepository(
             settingsRepository = SettingsRepository(),
             preferencesFlow = MutableStateFlow(mutablePreferencesOf()),
@@ -92,7 +92,6 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         keepLight = "keep-light",
         titleBarMode = "title-mode",
         readMenuBlurAlpha = 37,
-        eyeProtectionIntensity = 73,
         showBrightnessView = "brightness-view",
         brightnessVwPos = "brightness-pos",
         readBrightness = 73,
@@ -119,9 +118,6 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(textBottomJustify = false),
         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),
         base.copy(mouseWheelPage = false),
@@ -157,9 +153,6 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
-    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
-    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

+ 20 - 3
app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt

@@ -6,25 +6,39 @@ import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ThemeSettingsMappingTest {
 
     @Test
-    fun `Theme gateway 持久化边界固定为 59 键`() {
+    fun `手动护眼和跟随深色模式是独立配置来源`() {
+        val automatic = ThemeSettings(
+            eyeProtectionEnabled = false,
+            eyeProtectionAutoNight = true,
+        )
+
+        assertFalse(automatic.eyeProtectionEnabled)
+        assertTrue(automatic.eyeProtectionAutoNight)
+        assertTrue(automatic.isEyeProtectionConfigured)
+    }
+
+    @Test
+    fun `Theme gateway 持久化边界固定为 60 键`() {
         val actualKeys = ThemeSettings().toGatewayPrefMap().keys
         val expectedKeys = ThemeSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(59, actualKeys.size)
+        assertEquals(60, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
         assertFalse(PreferKey.customMode in actualKeys)
         assertFalse(PreferKey.bookInfoInputColor in actualKeys)
     }
 
     @Test
-    fun `Theme gateway 59 键写读映射逐字段对应`() {
+    fun `Theme gateway 60 键写读映射逐字段对应`() {
         themeMappingSamples().forEach { expected ->
             assertEquals(expected.expectedGatewayPrefMap(), expected.toGatewayPrefMap())
             assertEquals(
@@ -186,6 +200,7 @@ private fun themeMappingSamples(): List<ThemeSettings> {
         itemDividerColor = 126,
         eyeProtectionEnabled = false,
         colorTemperature = 127,
+        eyeProtectionAutoNight = false,
         eyeProtectionSchedule = false,
         eyeProtectionStartTime = "start-time",
         eyeProtectionEndTime = "end-time",
@@ -207,6 +222,7 @@ private fun themeMappingSamples(): List<ThemeSettings> {
         base.copy(bookInfoFollowCoverColor = true),
         base.copy(enableItemDivider = true),
         base.copy(eyeProtectionEnabled = true),
+        base.copy(eyeProtectionAutoNight = true),
         base.copy(eyeProtectionSchedule = true),
         base.copy(showRefactorTip = true),
         base.copy(enableCustomTagColors = true),
@@ -267,6 +283,7 @@ private fun ThemeSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.itemDividerColor to itemDividerColor,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
     PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
     PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
     PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,

+ 34 - 10
app/src/test/java/io/legado/app/ui/book/read/EyeProtectionTest.kt

@@ -1,15 +1,19 @@
 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 {
 
+    @Test
+    fun `跟随深色模式开启时护眼配置视为已启用`() {
+        assertTrue(EyeProtectionUiState(enabled = false, autoNight = true).configured)
+        assertFalse(EyeProtectionUiState(enabled = false, autoNight = false).configured)
+    }
+
     @Test
     fun `零强度不改变颜色`() {
         assertArrayEquals(
@@ -38,21 +42,35 @@ class EyeProtectionTest {
         )
     }
 
-    @Test
-    fun `自动模式跟随当前主题`() {
-        assertEquals(true, EyeProtection.syncEnabledForNight(isNight = true, autoNight = true))
-        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,
+        autoNight: Boolean = false,
+        isDark: Boolean = false,
         schedule: Boolean = true,
         start: String = "22:00",
         end: String = "07:00",
-    ) = EyeProtection.isActiveAt(enabled, schedule, start, end, LocalTime.of(hour, minute))
+    ) = EyeProtection.isActiveAt(
+        enabled, autoNight, isDark, schedule, start, end, LocalTime.of(hour, minute)
+    )
+
+    @Test
+    fun `手动开关与跟随深色模式独立决定是否生效`() {
+        // 手动开启后,浅色模式下仍然生效
+        assertTrue(activeAt(3, enabled = true, autoNight = true, isDark = false, schedule = false))
+        // 仅开启跟随深色时,浅色不生效、深色生效
+        assertFalse(activeAt(3, enabled = false, autoNight = true, isDark = false, schedule = false))
+        assertTrue(activeAt(3, enabled = false, autoNight = true, isDark = true, schedule = false))
+        // 仅手动开启时,深浅色不参与判定
+        assertTrue(activeAt(3, enabled = true, autoNight = false, isDark = false, schedule = false))
+    }
+
+    @Test
+    fun `跟随深色模式仍受定时时段约束`() {
+        assertTrue(activeAt(23, autoNight = true, isDark = true))
+        assertFalse(activeAt(12, autoNight = true, isDark = true))
+    }
 
     @Test
     fun `总开关关闭时永不生效`() {
@@ -90,6 +108,12 @@ class EyeProtectionTest {
         assertTrue(activeAt(12, start = "22:00", end = ""))
     }
 
+    @Test
+    fun `定时时间兼容 Unicode 数字`() {
+        assertTrue(activeAt(23, start = "٢٢:٠٠", end = "٠٧:٠٠"))
+        assertFalse(activeAt(12, start = "٢٢:٠٠", end = "٠٧:٠٠"))
+    }
+
     @Test
     fun `起止时间相同视为全天生效`() {
         assertTrue(activeAt(3, start = "08:00", end = "08:00"))

+ 2 - 0
app/src/test/java/io/legado/app/ui/main/MainNavigatorReadAloudTest.kt

@@ -9,6 +9,7 @@ class MainNavigatorReadAloudTest {
     @Test
     fun `opens cloud TTS manager on top of reader`() {
         val reader = MainRouteReadBook(bookUrl = "book")
+        val cloudTts = MainRouteCloudTtsEngines(bookUrl = "book")
         val backStack = mutableListOf<NavKey>(MainRouteHome, reader)
 
         MainNavigator.navigateToRoute(backStack, MainRouteCloudTtsEngines())
@@ -18,6 +19,7 @@ class MainNavigatorReadAloudTest {
 
     @Test
     fun `resets to home before cloud TTS manager from unrelated route`() {
+        val cloudTts = MainRouteCloudTtsEngines()
         val backStack = mutableListOf<NavKey>(
             MainRouteHome,
             MainRouteSettings,

+ 23 - 0
app/src/test/java/io/legado/app/ui/widget/components/dialog/TimePickerDialogTest.kt

@@ -0,0 +1,23 @@
+package io.legado.app.ui.widget.components.dialog
+
+import java.util.Locale
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TimePickerDialogTest {
+
+    @Test
+    fun `时间值在非拉丁数字语言下仍使用 ASCII 格式`() {
+        val original = Locale.getDefault()
+        try {
+            Locale.setDefault(Locale.forLanguageTag("ar"))
+            assertEquals("22:07", formatTimeValue(hour = 22, minute = 7))
+            assertEquals(22, parseTimeNumber("٢٢"))
+            assertEquals(7, parseTimeNumber("٠٧"))
+            assertNull(parseTimeNumber("999999999999"))
+        } finally {
+            Locale.setDefault(original)
+        }
+    }
+}

+ 31 - 0
app/src/test/java/io/legado/app/utils/HtmlFormatterTest.kt

@@ -0,0 +1,31 @@
+package io.legado.app.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HtmlFormatterTest {
+
+    @Test
+    fun formatDisplayText_removesScriptAndStyleContents() {
+        val result = HtmlFormatter.formatDisplayText(
+            """
+                <style>.intro { color: red; }</style>
+                <p>第一段</p>
+                <script>window.alert('bad')</script>
+                <div>第二段</div>
+            """.trimIndent()
+        )
+
+        assertTrue(result.contains("第一段"))
+        assertTrue(result.contains("第二段"))
+        assertFalse(result.contains("color: red"))
+        assertFalse(result.contains("window.alert"))
+    }
+
+    @Test
+    fun formatDisplayText_keepsPlainText() {
+        assertEquals("普通简介", HtmlFormatter.formatDisplayText("普通简介"))
+    }
+}

+ 40 - 26
baselineProfile/src/main/java/io/legado/baselineprofile/BaselineProfileGenerator.kt

@@ -5,15 +5,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Direction
 import androidx.test.uiautomator.Until
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import java.util.regex.Pattern
 
 /**
- * This test class generates a basic startup baseline profile for the target package.
- **/
+ * 生成 baseline profile。
+ *
+ * 关键:journey 必须真正打开一本书进入阅读器,否则开书热路径(Compose 组合、
+ * ChapterProvider/TextChapterLayout 排版、ReadView 绘制)不会被采样进 profile,
+ * release 首次开书只能主线程内联 JIT,产生明显加载卡顿。
+ *
+ * 运行前提:目标设备书架里至少有一本书(本仓库用连接的真机生成,见 useConnectedDevices)。
+ */
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 class BaselineProfileGenerator {
@@ -33,38 +39,46 @@ class BaselineProfileGenerator {
             pressHome()
             startActivityAndWait()
 
+            // 等书架加载完成。
             val bookshelfSelector = By.desc("bookshelf_list")
             device.wait(Until.hasObject(bookshelfSelector), 10000)
-
             device.waitForIdle()
             Thread.sleep(2000)
 
-            val screenWidth = device.displayWidth
-            val screenHeight = device.displayHeight
-            val startX = screenWidth / 2
-            val startY = (screenHeight * 0.75).toInt()
-            val endY = (screenHeight * 0.25).toInt()
+            val w = device.displayWidth
+            val h = device.displayHeight
 
-            repeat(3) {
-                device.swipe(startX, startY, startX, endY, 25)
-                Thread.sleep(1000)
+            // 打开第一本书进入阅读器。
+            // 注意:开书前不要滑动书架——下滑会让顶栏展开、把书目挤下去,位置漂移点不中。
+            // 书目是 Compose 语义节点,uiautomator 中 clickable=false,UiObject2.click() 不可靠,
+            // 因此用「书特有的进度描述」(未读/已读/第N章)精确定位书节点,取其 bounds 中心,
+            // 用原始坐标点击(等价 input tap,最稳)。找不到时退回已验证的左上角书位坐标。
+            val bookMatcher = By.desc(Pattern.compile(".*(未读|已读|读到|第.{1,8}章).*"))
+            val bookNode = device.wait(Until.findObject(bookMatcher), 5000)
+            if (bookNode != null) {
+                val b = bookNode.visibleBounds
+                device.click(b.centerX(), b.centerY())
+            } else {
+                device.click((w * 0.18f).toInt(), (h * 0.37f).toInt())
             }
+            device.waitForIdle()
+            Thread.sleep(3000)
+
+            // 若点书进的是书籍详情页,点“阅读”FAB 进入阅读器;已直接进阅读器则为 null,跳过。
+            device.wait(Until.findObject(By.text("阅读")), 2000)?.click()
+            device.waitForIdle()
+
+            // 等阅读器首屏排版完成。
+            Thread.sleep(3500)
 
-            repeat(2) {
-                device.swipe(startX, endY, startX, startY, 10)
-                Thread.sleep(1000)
+            // 翻几页,顺带采样相邻章排版与翻页绘制。
+            repeat(4) {
+                device.swipe((w * 0.85f).toInt(), h / 2, (w * 0.15f).toInt(), h / 2, 8)
+                Thread.sleep(900)
             }
 
-            // "nav_my"
-            /* val tabs = listOf("nav_explore", "nav_rss", "nav_bookshelf")
-            for (tabDesc in tabs) {
-                val tab = device.wait(Until.findObject(By.desc(tabDesc)), 5000)
-                if (tab != null) {
-                    val bounds = tab.visibleBounds
-                    device.click(bounds.centerX(), bounds.centerY())
-                    Thread.sleep(3000)
-                }
-            }*/
+            device.pressBack()
+            Thread.sleep(1500)
         }
     }
-}
+}

Some files were not shown because too many files changed in this diff