Quellcode durchsuchen

refactor(R2.2): 朗读/样式/开书/书签四域摘出,ReadBookViewModel 达到 2500 行验收线

R2 终态验收「ReadBookViewModel ≤ 2500 行」。3571 → 2494。四个域:

**朗读域 → `ReadAloudDelegate`**(-400 行):朗读设置读写、四个数值选择弹层、
播放传输控制、声音目录同步、TTS 缓存清理、多角色朗读的合成管线重启。

**阅读样式域 → `ReadStyleDelegate`**(-400 行):字体、11 路取色、背景图、
样式方案增删改导入导出、日夜切换与环境光提醒、护眼。日夜提醒的冷却时间戳、
两个「已划掉」标记和提醒队列一并搬进去,`ReadConfigUpdateDelegate` 改主题时
经 `resetDayNightReminderDismissal()` 复位。

**开书/换源域 → `ReadBookLoadDelegate`**(-330 行):从 intent 解析书、装载目录
与正文、本地文件缺失处理、手动与自动换源、云端进度双向同步。VM 只留
`initData`/`initReadBookConfig`/`changeTo` 等外部入口的转发。

**书签域 → `ReadBookmarkDelegate`**(-50 行)。

四个都**无自持状态**,与 `ReadConfigUpdateDelegate` 同形:状态留在
`ReadBookUiState`(朗读的 20 来个字段被四个 composable 直读、`styleConfig` 的重建
由 `collectReadStyle()` 统一驱动、`isInitFinish` 是 ReadView 首帧的放行门闩),
读写一律经 Host。硬搬状态要同时改多个 composable 的入参,收益不抵风险。

`configUpdateDelegate` 与 `styleDelegate` 互相引用(前者复位提醒标记、后者派发
StyleSelect),两处补显式类型注解打断 Kotlin 的递归类型推断。

护栏:DOMAINS 加四行,另加一条**行数验收线**——超过 2500 就红,逼新功能长成
delegate 而不是长回 VM。反证:阈值改 2400 即红。

顺带修了我自己刚加的两条护栏的误报(是它们自己报出来的):
- 开书域原用依赖名当标记,把 VM 里正当的 delegate 装配误判成逻辑回流,改用调用点;
- CallBack 护栏原按方法名查 override,把 `ReadBookLoadDelegate.Host.sureNewProgress`
  误判成回归。Kotlin 没有超类型就写不出 override,方法名检查本就多余,已删。

全量单测 429 → 430。

回归清单:朗读全流程(起停/上下段/上下章/定时/语速/多角色切换/预下载数与
并发数与段间隔与缓存清理四个选择器/胶囊显示与拖动与复位/默认界面切换/清缓存)、
阅读样式(换字体与系统字体/11 处取色/背景图含分日夜/样式方案增删与导入导出/
预设主题/日夜切换与环境光提醒/护眼各项)、开书(书架与最近阅读两条入口/
本地书缺文件的授权流程/换源与自动换源/进度冲突确认)、书签(菜单加书签、
划词加书签、保存、删除)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 vor 3 Monaten
Ursprung
Commit
13994a79fe

+ 448 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -0,0 +1,448 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import io.legado.app.R
+import io.legado.app.constant.EventBus
+import io.legado.app.data.entities.HttpTTS
+import io.legado.app.data.repository.HttpTtsRepository
+import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
+import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
+import io.legado.app.model.ReadAloud
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.model.ReadBook
+import io.legado.app.service.BaseReadAloudService
+import io.legado.app.utils.TTSCacheUtils
+import io.legado.app.utils.postEvent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * 朗读域(R2.2 续批)。
+ *
+ * 管朗读设置的读写、四个数值选择弹层、播放传输控制、声音目录同步和 TTS 缓存清理。
+ *
+ * **无自持状态**:朗读的 20 来个字段散落在 [ReadBookUiState] 里,被 `ReadAloudScreen`、
+ * `ReadAloudConfigContent`、`ReadBookScreen`、`ReadBookRouteScreen` 四处直读——
+ * 搬出去要同时改这四个 composable 的入参。故与 [ReadConfigUpdateDelegate] /
+ * [ReadButtonConfigDelegate] 同形:状态留在 UiState,读写一律经 [Host]。
+ */
+class ReadAloudDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val readSettingsRepository: ReadSettingsRepository,
+    private val readAloudSettingsRepository: ReadAloudSettingsRepository,
+    private val readAloudSessionStore: ReadAloudSessionStore,
+    private val httpTtsRepository: HttpTtsRepository,
+    private val aiProfileGateway: AiProfileGateway,
+    private val syncReadAloudVoicesUseCase: SyncReadAloudVoicesUseCase,
+) {
+
+    interface Host {
+        val uiState: ReadBookUiState
+
+        /** 朗读预下载章节数住在 ReadPreferences,不在 ReadBookUiState。 */
+        val preDownloadNum: Int
+
+        /** 系统 TTS 引擎清单(VM 侧 lazy,构造代价高,只取一次)。 */
+        val systemTtsEngines: List<TextToSpeech.EngineInfo>
+
+        fun updateState(transform: (ReadBookUiState) -> ReadBookUiState)
+
+        fun emitEffect(effect: ReadBookEffect)
+
+        suspend fun emitEffectAwait(effect: ReadBookEffect)
+
+        fun openReadMenuRoute(route: ReadBookMenuRoute)
+
+        /** 朗读进度(TTS 回调上报的章内偏移),VM 用独立 flow 暴露给胶囊。 */
+        fun publishReadAloudProgress(chapterStart: Int)
+    }
+
+    /** 订阅朗读设置,投影进 UiState。VM 构造时调一次。 */
+    fun collectPreferences() {
+        scope.launch {
+            readAloudSettingsRepository.preferences.collect { prefs ->
+                host.updateState {
+                    it.copy(
+                        readAloudIgnoreAudioFocus = prefs.ignoreAudioFocus,
+                        readAloudPauseOnPhoneCall = prefs.pauseReadAloudWhilePhoneCalls,
+                        readAloudWakeLock = prefs.readAloudWakeLock,
+                        showReadAloudCapsule = prefs.showReadAloudCapsule,
+                        capsuleAutoCollapse = prefs.capsuleAutoCollapse,
+                        readAloudCapsuleOffsetX = prefs.capsuleOffsetX,
+                        readAloudCapsuleOffsetY = prefs.capsuleOffsetY,
+                        readAloudMediaButtonPerNext = prefs.mediaButtonPerNext,
+                        readAloudByPage = prefs.readAloudByPage,
+                        readAloudSystemMediaCompat = prefs.systemMediaControlCompatibilityChange,
+                        readAloudStreamAudio = prefs.streamReadAloudAudio,
+                        readAloudTtsFollowSys = prefs.ttsFollowSys,
+                        readAloudTtsSpeechRate = prefs.ttsSpeechRate,
+                        readAloudTtsTimer = prefs.ttsTimer,
+                        speechAnalysisMode = prefs.speechAnalysisMode,
+                        useMultiSpeaker = prefs.useMultiSpeaker,
+                        defaultReadAloudInterface = prefs.defaultInterface,
+                        preDownloadNum = host.preDownloadNum,
+                        audioCacheCleanTime = prefs.audioCacheCleanTime,
+                        readAloudParagraphInterval = prefs.ttsParagraphInterval,
+                    )
+                }
+            }
+        }
+    }
+
+    /**
+     * 刷新声音目录。朗读引擎可能在 cloudtts 页被新增/删除,
+     * 打开朗读设置弹层和 VM 构造时各同步一次。
+     */
+    suspend fun syncConfiguredTtsVoices(
+        systemTtsLabel: String = context.getString(R.string.system_tts),
+        httpTtsList: List<HttpTTS> = httpTtsRepository.getAllSync(),
+    ) {
+        syncReadAloudVoicesUseCase(
+            entries = buildList {
+                add(
+                    VoiceCatalogEntry(
+                        engineType = ReadAloudVoice.ENGINE_SYSTEM,
+                        engineId = "",
+                        displayName = systemTtsLabel,
+                    )
+                )
+                host.systemTtsEngines.forEach { engine ->
+                    add(
+                        VoiceCatalogEntry(
+                            engineType = ReadAloudVoice.ENGINE_SYSTEM,
+                            engineId = engine.name,
+                            displayName = engine.label,
+                        )
+                    )
+                }
+                httpTtsList.forEach { httpTts ->
+                    add(
+                        VoiceCatalogEntry(
+                            engineType = ReadAloudVoice.ENGINE_HTTP,
+                            engineId = httpTts.id.toString(),
+                            displayName = httpTts.name,
+                            sourceRevision = httpTts.lastUpdateTime,
+                        )
+                    )
+                }
+            },
+            managedSources = setOf(ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS),
+            removeMissingEngineTypes = setOf(ReadAloudVoice.ENGINE_HTTP),
+        )
+    }
+
+    // --- 播放控制 ---
+
+    fun updateProgress(chapterStart: Int) {
+        if (BaseReadAloudService.isPlay() && chapterStart > 0) {
+            host.publishReadAloudProgress(chapterStart)
+        }
+    }
+
+    fun togglePause() {
+        if (host.uiState.isReadAloudPaused) {
+            ReadAloud.resume(context)
+            host.updateState { it.copy(isReadAloudPaused = false) }
+        } else {
+            ReadAloud.pause(context)
+            host.updateState { it.copy(isReadAloudPaused = true) }
+        }
+    }
+
+    fun stop() {
+        ReadAloud.stop(context)
+        host.updateState { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
+    }
+
+    fun prevParagraph() = ReadAloud.prevParagraph(context)
+
+    fun nextParagraph() = ReadAloud.nextParagraph(context)
+
+    fun prevChapter() = ReadBook.moveToPrevChapter(upContent = true, toLast = false)
+
+    fun nextChapter() = ReadBook.moveToNextChapter(true)
+
+    // --- 界面入口 ---
+
+    /** 媒体键/胶囊触发的默认朗读界面:按设置决定开播放器弹层还是经典控制面板。 */
+    fun openDefaultInterface() {
+        if (
+            host.uiState.defaultReadAloudInterface ==
+            ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
+        ) {
+            host.updateState {
+                it.copy(
+                    menuState = ReadBookMenuState(),
+                    activeSheet = ReadBookSheet.ReadAloudPlayer,
+                )
+            }
+        } else {
+            host.openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
+        }
+    }
+
+    fun openPlayer() {
+        host.updateState {
+            it.copy(menuState = ReadBookMenuState(), activeSheet = ReadBookSheet.ReadAloudPlayer)
+        }
+    }
+
+    fun openClassicControls() {
+        host.updateState { it.copy(activeSheet = null) }
+        host.openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
+    }
+
+    fun openConfigSheet() {
+        host.updateState { it.copy(activeSheet = ReadBookSheet.ReadAloudConfig) }
+        scope.launch { syncConfiguredTtsVoices() }
+    }
+
+    fun openTtsEnginesAndVoices() {
+        host.updateState { it.copy(activeSheet = null) }
+        host.emitEffect(ReadBookEffect.OpenTtsEnginesAndVoices)
+    }
+
+    fun openTtsCache() {
+        host.updateState { it.copy(activeSheet = null) }
+        host.emitEffect(ReadBookEffect.OpenTtsCache)
+    }
+
+    fun openBookVoiceCasting() {
+        ReadBook.book?.bookUrl?.let { bookUrl ->
+            host.updateState { it.copy(activeSheet = null) }
+            host.emitEffect(ReadBookEffect.OpenBookVoiceCasting(bookUrl))
+        }
+    }
+
+    fun openSystemTtsSettings() {
+        host.emitEffect(ReadBookEffect.OpenSystemTtsSettings)
+    }
+
+    fun clearTtsCache() {
+        TTSCacheUtils.clearTtsCache()
+        host.emitEffect(
+            ReadBookEffect.TtsCacheCleared(context.getString(R.string.clear_cache_success))
+        )
+    }
+
+    // --- 四个数值选择弹层 ---
+
+    fun openPreDownloadNumPicker() {
+        host.updateState {
+            it.copy(
+                preDownloadNum = host.preDownloadNum,
+                activeSheet = ReadBookSheet.PreDownloadConfig,
+            )
+        }
+    }
+
+    fun openPreSynthesisConcurrencyPicker() {
+        host.updateState {
+            it.copy(
+                preSynthesisConcurrency =
+                    readAloudSettingsRepository.currentSettings.ttsPreSynthesisConcurrency,
+                activeSheet = ReadBookSheet.PreSynthesisConcurrencyConfig,
+            )
+        }
+    }
+
+    fun openParagraphIntervalPicker() {
+        host.updateState {
+            it.copy(
+                readAloudParagraphInterval =
+                    readAloudSettingsRepository.currentSettings.ttsParagraphInterval,
+                activeSheet = ReadBookSheet.ParagraphIntervalConfig,
+            )
+        }
+    }
+
+    fun openCacheCleanTimePicker() {
+        host.updateState {
+            it.copy(
+                audioCacheCleanTime = readAloudSettingsRepository.currentSettings.audioCacheCleanTime,
+                activeSheet = ReadBookSheet.AudioCacheCleanConfig,
+            )
+        }
+    }
+
+    fun applyPreDownloadNum(value: Int) {
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readSettingsRepository.setPreDownloadNum(value)
+        }
+        host.updateState {
+            it.copy(preDownloadNum = value, activeSheet = ReadBookSheet.ReadAloudConfig)
+        }
+    }
+
+    fun applyPreSynthesisConcurrency(value: Int) {
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readAloudSettingsRepository.update {
+                it.copy(ttsPreSynthesisConcurrency = value.coerceIn(1, 8))
+            }
+        }
+        host.updateState {
+            it.copy(preSynthesisConcurrency = value, activeSheet = ReadBookSheet.ReadAloudConfig)
+        }
+    }
+
+    fun applyAudioCacheCleanTime(value: Int) {
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readAloudSettingsRepository.update { it.copy(audioCacheCleanTime = value) }
+        }
+        host.updateState {
+            it.copy(audioCacheCleanTime = value, activeSheet = ReadBookSheet.ReadAloudConfig)
+        }
+    }
+
+    fun applyParagraphInterval(value: Int) {
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readAloudSettingsRepository.update { it.copy(ttsParagraphInterval = value) }
+        }
+        host.updateState { it.copy(readAloudParagraphInterval = value) }
+    }
+
+    // --- 开关类设置 ---
+
+    fun setIgnoreAudioFocus(value: Boolean) = updateSettings { it.copy(ignoreAudioFocus = value) }
+
+    fun setPauseOnPhoneCall(value: Boolean) =
+        updateSettings { it.copy(pauseReadAloudWhilePhoneCalls = value) }
+
+    fun setWakeLock(value: Boolean) = updateSettings { it.copy(readAloudWakeLock = value) }
+
+    fun setShowCapsule(value: Boolean) = updateSettings { it.copy(showReadAloudCapsule = value) }
+
+    fun setCapsuleAutoCollapse(value: Boolean) =
+        updateSettings { it.copy(capsuleAutoCollapse = value) }
+
+    fun setMediaButtonPerNext(value: Boolean) = updateSettings { it.copy(mediaButtonPerNext = value) }
+
+    fun setSystemMediaCompat(value: Boolean) =
+        updateSettings { it.copy(systemMediaControlCompatibilityChange = value) }
+
+    fun setByPage(value: Boolean) {
+        updateSettings { it.copy(readAloudByPage = value) }
+        if (value) postEvent(EventBus.MEDIA_BUTTON, false)
+    }
+
+    fun setStreamAudio(value: Boolean) {
+        updateSettings { it.copy(streamReadAloudAudio = value) }
+        if (value) postEvent(EventBus.MEDIA_BUTTON, false)
+    }
+
+    fun resetCapsulePosition() {
+        host.updateState { it.copy(readAloudCapsuleOffsetX = 0f, readAloudCapsuleOffsetY = 0f) }
+        updateSettings { it.copy(capsuleOffsetX = 0f, capsuleOffsetY = 0f) }
+    }
+
+    fun setCapsulePosition(x: Float, y: Float) {
+        host.updateState { it.copy(readAloudCapsuleOffsetX = x, readAloudCapsuleOffsetY = y) }
+        updateSettings { it.copy(capsuleOffsetX = x, capsuleOffsetY = y) }
+    }
+
+    fun setTtsFollowSys(value: Boolean) {
+        updateSettings { it.copy(ttsFollowSys = value) }
+        host.updateState { it.copy(readAloudTtsFollowSys = value) }
+    }
+
+    fun setTtsTimer(value: Int) {
+        val timer = PlaybackTimer.normalize(value)
+        ReadAloud.setTimer(context, timer)
+        updateSettings { it.copy(ttsTimer = timer) }
+        host.updateState { it.copy(readAloudTtsTimer = timer) }
+    }
+
+    fun setTtsSpeechRate(value: Int) {
+        scope.launch {
+            readAloudSettingsRepository.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }
+            ReadAloud.upTtsSpeechRate(context)
+        }
+        host.updateState { it.copy(readAloudTtsSpeechRate = value) }
+    }
+
+    fun setDefaultInterface(value: String) {
+        updateSettings {
+            it.copy(
+                defaultInterface = value.takeIf { candidate ->
+                    candidate in ReadAloudSettingsRepository.AVAILABLE_INTERFACES
+                } ?: ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC
+            )
+        }
+        host.updateState { it.copy(defaultReadAloudInterface = value) }
+    }
+
+    /** 非规则模式要求已配置 AI 模型,否则拒绝切换并提示。 */
+    fun setSpeechAnalysisMode(value: String) {
+        scope.launch {
+            if (value != "rule") {
+                val configured = aiProfileGateway.getTaskPreset(AiTaskType.ANALYZE_SPEECH)
+                    ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
+                if (configured == null) {
+                    host.emitEffectAwait(
+                        ReadBookEffect.ShowToast(
+                            context.getString(R.string.speech_analysis_ai_model_required)
+                        )
+                    )
+                    return@launch
+                }
+            }
+            readAloudSettingsRepository.update { it.copy(speechAnalysisMode = value) }
+            host.updateState { it.copy(speechAnalysisMode = value) }
+        }
+    }
+
+    /**
+     * 多角色朗读开关。正在朗读时必须重启朗读服务才能换掉合成管线,
+     * 重启前记住页内位置,等服务真的回到 Idle 再重放,避免新旧管线叠音。
+     */
+    fun setUseMultiSpeaker(value: Boolean) {
+        scope.launch {
+            val shouldRestart = BaseReadAloudService.isRun
+            val resumePlaying = shouldRestart && !BaseReadAloudService.pause
+            val chapter = ReadBook.curTextChapter
+            val chapterPosition = readAloudSessionStore.state.value.playback.chapterPosition
+            readAloudSettingsRepository.update { it.copy(useMultiSpeaker = value) }
+            host.updateState { it.copy(useMultiSpeaker = value) }
+            if (shouldRestart && chapter != null) {
+                val pageIndex = chapter.getPageIndexByCharIndex(chapterPosition)
+                val startPos = chapterPosition - chapter.getReadLength(pageIndex)
+                ReadAloud.stop(context)
+                val stopped = withTimeoutOrNull(2_000) {
+                    readAloudSessionStore.state.first {
+                        it.status == ReadAloudSessionStatus.Idle
+                    }
+                }
+                if (stopped == null) return@launch
+                ReadAloud.refreshReadAloudClass()
+                ReadAloud.play(
+                    context = context,
+                    play = resumePlaying,
+                    pageIndex = pageIndex,
+                    startPos = startPos.coerceAtLeast(0),
+                )
+            }
+        }
+    }
+
+    private inline fun updateSettings(
+        crossinline transform: (ReadAloudSettings) -> ReadAloudSettings,
+    ) {
+        scope.launch {
+            readAloudSettingsRepository.update { transform(it) }
+        }
+    }
+}

+ 419 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt

@@ -0,0 +1,419 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.content.Intent
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.EventBus
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookProgress
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.model.ReadingProgress
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
+import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import io.legado.app.domain.usecase.UploadReadingProgressUseCase
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isLocalModified
+import io.legado.app.help.book.simulatedTotalChapterNum
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.ReadBook
+import io.legado.app.model.SourceCallBack
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.service.BaseReadAloudService
+import io.legado.app.utils.mapParallelSafe
+import io.legado.app.utils.postEvent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onEmpty
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.take
+import java.io.FileNotFoundException
+import kotlin.coroutines.coroutineContext
+
+/**
+ * 开书 / 目录加载 / 换源 / 进度同步域(R2.2 续批)。
+ *
+ * 从 intent 解析出书,装载目录与正文,处理本地文件缺失、换源(手动与自动)、
+ * 以及与云端阅读进度的双向同步。
+ *
+ * **无自持状态**:唯一的状态是 `isInitFinish`(ReadView 首帧靠它放行前后章排版,
+ * 见 `prepareCachedChapterFallback` 的说明),必须留在 [ReadBookUiState]。
+ * 故与 [ReadConfigUpdateDelegate] 同形,读写经 [Host]。
+ */
+class ReadBookLoadDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository,
+    private val readSettingsRepository: ReadSettingsRepository,
+    private val backupSettingsGateway: BackupSettingsGateway,
+    private val changeSourceSettingsGateway: ChangeSourceSettingsGateway,
+    private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
+    private val changeBookSourceUseCase: ChangeBookSourceUseCase,
+    private val getReadingProgressUseCase: GetReadingProgressUseCase,
+    private val uploadReadingProgressUseCase: UploadReadingProgressUseCase,
+) {
+
+    interface Host {
+        /** 刚 initData 完的一次性标记:此时不再弹进度冲突确认。 */
+        var justInitData: Boolean
+
+        fun setInitFinish()
+
+        fun emitEffect(effect: ReadBookEffect)
+
+        /** 云端进度比本地新,弹确认框。 */
+        fun sureNewProgress(progress: BookProgress)
+
+        /** 本地书文件读不到,去要书籍目录权限。 */
+        fun requestBooksDirPicker(reloadChapterList: Boolean)
+
+        /** 把 ReadPreferences 快照刷成最新,开书流程依赖它。 */
+        suspend fun syncReadPreferencesSnapshot()
+
+        fun openChapter(index: Int, durChapterPos: Int)
+    }
+
+    private var changeSourceCoroutine: Coroutine<*>? = null
+
+    suspend fun initReadBookConfig(intent: Intent) {
+        val bookUrl = intent.getStringExtra("bookUrl")
+        val book = when {
+            bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+            else -> bookRepository.getBook(bookUrl)
+        } ?: return
+        ReadBook.upReadBookConfig(book)
+    }
+
+    fun initData(intent: Intent, success: (() -> Unit)? = null) {
+        Coroutine.async(scope, Dispatchers.IO) {
+            host.syncReadPreferencesSnapshot()
+            ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
+            ReadBook.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
+            val bookUrl = intent.getStringExtra("bookUrl")
+            val book = when {
+                bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+                else -> bookRepository.getBook(bookUrl)
+            } ?: ReadBook.book
+            when {
+                book != null -> initBook(book)
+                else -> {
+                    ReadBook.upMsg(context.getString(R.string.no_book))
+                    AppLog.put("未找到书籍\nbookUrl:$bookUrl")
+                }
+            }
+            val index = intent.getIntExtra("index", -1)
+            val chapterPos = intent.getIntExtra("chapterPos", -1)
+            if (index >= 0 && chapterPos >= 0) {
+                ReadBook.saveCurrentBookProgress()
+                host.openChapter(index, chapterPos)
+            }
+        }.onSuccess {
+            success?.invoke()
+        }.onError {
+            val msg = "初始化数据失败\n${it.localizedMessage}"
+            ReadBook.upMsg(msg)
+            AppLog.put(msg, it)
+        }.onFinally {
+            ReadBook.saveRead()
+        }
+    }
+
+    /** 换书/重装目录后重走开书流程。VM 的「模拟阅读切换」和目录权限回来后也调它。 */
+    suspend fun initBook(book: Book) {
+        val isSameBook = ReadBook.book?.bookUrl == book.bookUrl
+        if (isSameBook) {
+            ReadBook.upData(book)
+        } else {
+            ReadBook.resetData(book)
+        }
+        host.setInitFinish()
+        if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
+            return
+        }
+        if (book.isLocal && !checkLocalBookFileExist(book)) {
+            return
+        }
+        if ((ReadBook.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
+            return
+        }
+        ReadBook.upMsg(null)
+
+        if (!isSameBook) {
+            ReadBook.loadContent(resetPageOffset = true) {
+                ReadBook.bookSource?.let {
+                    SourceCallBack.callBackBook(
+                        SourceCallBack.START_READ,
+                        it,
+                        book,
+                        ReadBook.curTextChapter?.chapter
+                    )
+                }
+            }
+        } else {
+            ReadBook.loadOrUpContent {
+                ReadBook.bookSource?.let {
+                    SourceCallBack.callBackBook(
+                        SourceCallBack.START_READ,
+                        it,
+                        book,
+                        ReadBook.curTextChapter?.chapter
+                    )
+                }
+            }
+        }
+        if (ReadBook.chapterChanged) {
+            ReadBook.chapterChanged = false
+        } else if (!(isSameBook && BaseReadAloudService.isRun) && ReadBook.inBookshelf) {
+            if (backupSettingsGateway.currentSettings.syncBookProgressPlus) {
+                ReadBook.syncProgress({ progress -> host.sureNewProgress(progress) })
+            } else {
+                syncBookProgress(book)
+            }
+        }
+        if (!book.isLocal && ReadBook.bookSource == null) {
+            autoChangeSource(book.name, book.author)
+            return
+        }
+    }
+
+    private fun checkLocalBookFileExist(book: Book): Boolean {
+        try {
+            LocalBook.getBookInputStream(book)
+            return true
+        } catch (e: Throwable) {
+            ReadBook.upMsg("打开本地书籍出错: ${e.localizedMessage}")
+            if (e is SecurityException || e is FileNotFoundException) {
+                host.requestBooksDirPicker(reloadChapterList = false)
+            }
+            return false
+        }
+    }
+
+    private suspend fun loadBookInfo(book: Book): Boolean {
+        val source = ReadBook.bookSource ?: return true
+        try {
+            WebBook.getBookInfoAwait(source, book, canReName = false)
+            return true
+        } catch (e: Throwable) {
+            coroutineContext.ensureActive()
+            ReadBook.upMsg("详情页出错: ${e.localizedMessage}")
+            return false
+        }
+    }
+
+    /** 重新拉目录。会话侧 `loadChapterList` 回调和 TOC 正则改动都走这里。 */
+    fun doLoadChapterList(book: Book) {
+        Coroutine.async(scope, Dispatchers.IO) {
+            if (loadChapterListAwait(book)) {
+                ReadBook.upMsg(null)
+            }
+        }
+    }
+
+    private suspend fun loadChapterListAwait(book: Book): Boolean {
+        if (book.isLocal) {
+            kotlin.runCatching {
+                LocalBook.getChapterList(book).let {
+                    bookRepository.replaceChaptersAndUpdateBook(book, it)
+                    ReadBook.onChapterListUpdated(book)
+                }
+                return true
+            }.onFailure {
+                when (it) {
+                    is SecurityException, is FileNotFoundException -> {
+                        host.requestBooksDirPicker(reloadChapterList = true)
+                    }
+                    else -> {
+                        AppLog.put("LoadTocError:${it.localizedMessage}", it)
+                        ReadBook.upMsg("LoadTocError:${it.localizedMessage}")
+                    }
+                }
+                return false
+            }
+        } else {
+            ReadBook.bookSource?.let {
+                val oldBook = book.copy()
+                WebBook.getChapterListAwait(it, book, true)
+                    .onSuccess { cList ->
+                        if (oldBook.bookUrl == book.bookUrl) {
+                            bookRepository.update(book)
+                        } else {
+                            bookRepository.replace(oldBook, book)
+                            BookHelp.updateCacheFolder(oldBook, book)
+                        }
+                        bookRepository.deleteChaptersByBook(oldBook.bookUrl)
+                        bookRepository.insertChapters(*cList.toTypedArray())
+                        ReadBook.onChapterListUpdated(book)
+                        return true
+                    }.onFailure {
+                        coroutineContext.ensureActive()
+                        ReadBook.upMsg(context.getString(R.string.error_load_toc))
+                        return false
+                    }
+            }
+        }
+        return true
+    }
+
+    fun syncBookProgress(
+        book: Book,
+        alertSync: ((progress: BookProgress) -> Unit)? = null
+    ) {
+        if (!backupSettingsGateway.currentSettings.syncBookProgress) return
+        Coroutine.async(scope, Dispatchers.IO) {
+            getReadingProgressUseCase.execute(book.name, book.author)?.toBookProgress()
+        }.onError {
+            AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it)
+        }.onSuccess { progress ->
+            progress ?: return@onSuccess
+            if (progress.durChapterIndex < book.durChapterIndex ||
+                (progress.durChapterIndex == book.durChapterIndex
+                        && progress.durChapterPos < book.durChapterPos)
+            ) {
+                alertSync?.invoke(progress)
+            } else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) {
+                ReadBook.setProgress(progress)
+                AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}")
+            }
+        }
+    }
+
+    fun isReadingProgressSyncConfigured(): Boolean {
+        return getReadingProgressUseCase.isConfigured
+    }
+
+    suspend fun uploadBookProgress(book: Book) {
+        uploadReadingProgressUseCase.execute(book.toReadingProgress())?.let { uploadTime ->
+            book.syncTime = uploadTime
+            bookRepository.update(book)
+        }
+    }
+
+    private fun Book.toReadingProgress() = ReadingProgress(
+        name = name,
+        author = author,
+        durChapterIndex = durChapterIndex,
+        durChapterPos = durChapterPos,
+        durChapterTime = durChapterTime,
+        durChapterTitle = durChapterTitle
+    )
+
+    private fun ReadingProgress.toBookProgress() = BookProgress(
+        name = name,
+        author = author,
+        durChapterIndex = durChapterIndex,
+        durChapterPos = durChapterPos,
+        durChapterTime = durChapterTime,
+        durChapterTitle = durChapterTitle
+    )
+
+    fun changeTo(book: Book, toc: List<BookChapter>) {
+        changeSourceCoroutine?.cancel()
+        changeSourceCoroutine = Coroutine.async(scope, Dispatchers.IO) {
+            ReadBook.upMsg(context.getString(R.string.loading))
+            applyChangeSource(book, toc)
+        }.onSuccess {
+            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+        }.onError {
+            AppLog.put("换源失败\n$it", it, true)
+            ReadBook.upMsg(null)
+        }
+    }
+
+    fun changeTo(book: Book) {
+        changeSourceCoroutine?.cancel()
+        changeSourceCoroutine = Coroutine.async(scope, Dispatchers.IO) {
+            ReadBook.upMsg(context.getString(R.string.loading))
+            val source = bookSourceRepository.getBookSource(book.origin)
+                ?: throw NoStackTraceException("书源不存在")
+            if (book.tocUrl.isEmpty()) {
+                WebBook.getBookInfoAwait(source, book)
+            }
+            val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
+            applyChangeSource(book, toc)
+        }.onSuccess {
+            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+        }.onError {
+            AppLog.put("换源失败\n$it", it, true)
+            ReadBook.upMsg(null)
+        }
+    }
+
+    private suspend fun applyChangeSource(book: Book, toc: List<BookChapter>) {
+        if (toc.isEmpty()) {
+            throw NoStackTraceException("换源目录为空")
+        }
+        val oldBook = ReadBook.book ?: throw NoStackTraceException("书籍不存在")
+        changeBookSourceUseCase.changeTo(
+            oldBook = oldBook,
+            newBook = book,
+            chapters = toc,
+            options = changeSourceSettingsGateway.currentSettings.migrationOptions(),
+        )
+        ReadBook.resetData(book)
+        ReadBook.upMsg(null)
+        ReadBook.loadContent(resetPageOffset = true)
+    }
+
+    private fun autoChangeSource(name: String, author: String) {
+        if (!readSettingsRepository.currentSettings.autoChangeSource) return
+        Coroutine.async(scope, Dispatchers.IO) {
+            val sources = bookSourceRepository.getAllTextEnabledPart()
+            flow {
+                for (source in sources) {
+                    source.getBookSource()?.let {
+                        emit(it)
+                    }
+                }
+            }.onStart {
+                ReadBook.upMsg(context.getString(R.string.source_auto_changing))
+            }.mapParallelSafe(downloadCacheSettingsGateway.currentSettings.threadCount) { source ->
+                val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
+                if (book.tocUrl.isEmpty()) {
+                    WebBook.getBookInfoAwait(source, book)
+                }
+                val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
+                val chapter = toc.getOrElse(book.durChapterIndex) {
+                    toc.last()
+                }
+                val nextChapter = toc.getOrElse(chapter.index) {
+                    toc.first()
+                }
+                WebBook.getContentAwait(
+                    bookSource = source,
+                    book = book,
+                    bookChapter = chapter,
+                    nextChapterUrl = nextChapter.url
+                )
+                book to toc
+            }.take(1).onEach { (book, toc) ->
+                changeTo(book, toc)
+            }.onEmpty {
+                throw NoStackTraceException("没有合适书源")
+            }.onCompletion {
+                ReadBook.upMsg(null)
+            }.catch {
+                AppLog.put("自动换源失败\n${it.localizedMessage}", it)
+                host.emitEffect(ReadBookEffect.ShowToast("自动换源失败\n${it.localizedMessage}"))
+            }.collect()
+        }
+    }
+
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 236 - 600
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt


+ 68 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -0,0 +1,68 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.repository.BookmarkRepository
+import io.legado.app.model.ReadBook
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * 书签域(R2.2 续批)。
+ *
+ * **无自持状态**:书签编辑器就是 `ReadBookSheet.Bookmark`,草稿随 sheet 参数走,
+ * `activeSheet` 是单一持有者,故读写经 [Host]。
+ */
+class ReadBookmarkDelegate(
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val bookmarkRepository: BookmarkRepository,
+) {
+
+    interface Host {
+        /** 打开/关闭书签弹层的同时收起阅读菜单。 */
+        fun setActiveSheet(sheet: ReadBookSheet?)
+    }
+
+    /** 从菜单「加书签」进入:以当前页正文预填草稿。 */
+    fun addForCurrentPage() {
+        scope.launch(IO) {
+            val book = ReadBook.book ?: return@launch
+            val chapter = ReadBook.curTextChapter ?: return@launch
+            val page = chapter.pages.getOrNull(ReadBook.durPageIndex) ?: return@launch
+            val bookmark = Bookmark(
+                bookName = book.name,
+                bookAuthor = book.author,
+                chapterIndex = chapter.chapter.index,
+                chapterName = chapter.title,
+                chapterPos = ReadBook.durPageIndex,
+                bookText = page.text,
+                content = "",
+            )
+            withContext(Main) {
+                host.setActiveSheet(ReadBookSheet.Bookmark(bookmark))
+            }
+        }
+    }
+
+    /** 从划词菜单「加书签」进入:草稿已由调用方按选中文本构造好。 */
+    fun openEditor(bookmark: Bookmark) {
+        host.setActiveSheet(ReadBookSheet.Bookmark(bookmark))
+    }
+
+    fun save(bookmark: Bookmark) {
+        scope.launch(IO) {
+            bookmarkRepository.save(bookmark)
+            host.setActiveSheet(null)
+        }
+    }
+
+    fun delete(bookmark: Bookmark) {
+        scope.launch(IO) {
+            bookmarkRepository.delete(bookmark)
+            host.setActiveSheet(null)
+        }
+    }
+}

+ 498 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadStyleDelegate.kt

@@ -0,0 +1,498 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.core.graphics.toColorInt
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.EventBus
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.ReadStyleColorKey
+import io.legado.app.domain.gateway.ReadStyleGateway
+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.settings.ThemeSettings
+import io.legado.app.domain.model.settings.isEyeProtectionConfigured
+import io.legado.app.model.ReadSessionState
+import io.legado.app.utils.hexString
+import io.legado.app.utils.postEvent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.FileNotFoundException
+import kotlin.time.Duration.Companion.milliseconds
+import androidx.core.graphics.ColorUtils as AndroidColorUtils
+
+/**
+ * 阅读样式域(R2.2 续批):字体、取色、背景图、样式方案导入导出、日夜切换与其提醒、护眼。
+ *
+ * **无自持状态**:`styleConfig` / `sheetConfig` / `activeReminder` / `eyeProtection`
+ * 都被菜单栏与各设置弹层直读,且 `styleConfig` 的重建由 VM 的 `collectReadStyle()`
+ * 统一驱动(见那里的三处触发说明)。故与 [ReadConfigUpdateDelegate] 同形:
+ * 状态留在 [ReadBookUiState],读写一律经 [Host]。
+ *
+ * 日夜提醒的三个私有变量(冷却时间戳、两个"已被划掉"标记)和提醒队列搬进本类——
+ * 它们只被日夜逻辑读写,`ReadConfigUpdateDelegate` 改主题时经
+ * [resetDayNightReminderDismissal] 复位。
+ */
+class ReadStyleDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val readSettingsRepository: ReadSettingsRepository,
+    private val readStyleGateway: ReadStyleGateway,
+    private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
+) {
+
+    interface Host {
+        val uiState: ReadBookUiState
+
+        /** 当前是否深色主题。取自 AppUiConfiguration,不是排版配置。 */
+        val isNightTheme: Boolean
+
+        fun updateState(transform: (ReadBookUiState) -> ReadBookUiState)
+
+        fun emitEffect(effect: ReadBookEffect)
+
+        fun applyConfigUpdate(update: ConfigUpdate)
+    }
+
+    private var lastSwitchDayNightReminderTime: Long = 0L
+    private val reminderQueue = ArrayDeque<ReminderUiState>()
+    private var hasDismissedDarkReminder = false
+    private var hasDismissedLightReminder = false
+
+    // --- 字体 ---
+
+    fun selectFont(path: String) {
+        readStyleGateway.updateCurrentStyle(stringMutation(ReadStyleStringKey.TextFont, path))
+        emitConfigUpdate(
+            ConfigUpdateAction.UpdateChapterStyle,
+            ConfigUpdateAction.ReloadContent,
+            ConfigUpdateAction.UpdateStyle,
+        )
+    }
+
+    fun selectTitleFont(path: String) {
+        readStyleGateway.updateCurrentStyle(stringMutation(ReadStyleStringKey.TitleFont, path))
+        emitConfigUpdate(
+            ConfigUpdateAction.UpdateChapterStyle,
+            ConfigUpdateAction.ReloadContent,
+            ConfigUpdateAction.UpdateStyle,
+        )
+    }
+
+    fun selectSystemTypeface(index: Int) = selectSystemTypeface(ReadStyleStringKey.TextFont, index)
+
+    fun selectTitleSystemTypeface(index: Int) =
+        selectSystemTypeface(ReadStyleStringKey.TitleFont, index)
+
+    private fun selectSystemTypeface(key: ReadStyleStringKey, index: Int) {
+        readStyleGateway.updateCurrentStyle(stringMutation(key, ""))
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readSettingsRepository.setSystemTypefaces(index)
+        }
+        emitConfigUpdate(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+    }
+
+    // --- 样式方案 ---
+
+    fun saveCurrentStyle() {
+        readStyleGateway.save()
+    }
+
+    fun addStyle() {
+        host.applyConfigUpdate(ConfigUpdate.StyleSelect(readStyleGateway.addStyle()))
+    }
+
+    fun deleteCurrentStyle() {
+        if (!readStyleGateway.deleteCurrentStyle()) return
+        host.updateState { it.copy(activeSheet = null) }
+        emitConfigUpdate(
+            ConfigUpdateAction.UpdateBackground,
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.ReloadContent,
+            ConfigUpdateAction.UpdatePageAnim,
+        )
+    }
+
+    fun applyPresetTheme(presetIndex: Int) {
+        if (!readStyleGateway.applyPreset(presetIndex)) return
+        emitConfigUpdate(
+            ConfigUpdateAction.UpdateBackground,
+            ConfigUpdateAction.UpdateBackgroundAlpha,
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateSystemUi,
+            ConfigUpdateAction.ReloadContent,
+            ConfigUpdateAction.UpdatePageAnim,
+        )
+    }
+
+    fun openBgTextConfig(index: Int) {
+        scope.launch {
+            readSettingsRepository.setStyleSelect(ReadSessionState.isComic, index)
+        }
+        host.updateState { it.copy(activeSheet = ReadBookSheet.BgTextConfig) }
+    }
+
+    // --- 背景图与导入导出 ---
+
+    fun applyBackgroundImage(uri: Uri) {
+        withStyleFileOperation("选择阅读背景图失败") {
+            val path = saveBackgroundImage(uri)
+            readStyleGateway.setCurrentBackgroundImage(path)
+            emitConfigUpdate(ConfigUpdateAction.UpdateBackground)
+            context.getString(R.string.success)
+        }
+    }
+
+    fun applyBackgroundImageForMode(uri: Uri, isNight: Boolean) {
+        withStyleFileOperation("选择阅读背景图失败") {
+            val path = saveBackgroundImage(uri)
+            readStyleGateway.setCurrentBackgroundImageForMode(path, isNight)
+            emitConfigUpdate(ConfigUpdateAction.UpdateBackground)
+            context.getString(R.string.success)
+        }
+    }
+
+    fun importConfig(uri: Uri) {
+        withStyleFileOperation("导入阅读样式失败") {
+            val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+                ?: throw FileNotFoundException(uri.toString())
+            readStyleGateway.importCurrentStyle(bytes)
+            emitConfigUpdate(
+                ConfigUpdateAction.UpdateBackground,
+                ConfigUpdateAction.UpdateStyle,
+                ConfigUpdateAction.ReloadContent,
+                ConfigUpdateAction.UpdatePageAnim,
+            )
+            context.getString(R.string.success)
+        }
+    }
+
+    fun exportConfig(uri: Uri) {
+        withStyleFileOperation("导出阅读样式失败") {
+            val bytes = readStyleGateway.exportCurrentStyle()
+            context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
+                ?: throw FileNotFoundException(uri.toString())
+            context.getString(R.string.export_success)
+        }
+    }
+
+    private fun saveBackgroundImage(uri: Uri): String {
+        val name = queryDisplayName(uri)
+        return context.contentResolver.openInputStream(uri)?.use {
+            readStyleGateway.saveBackgroundImage(it, name)
+        } ?: throw FileNotFoundException(uri.toString())
+    }
+
+    private inline fun withStyleFileOperation(
+        logTag: String,
+        crossinline block: () -> String,
+    ) {
+        scope.launch(IO) {
+            runCatching { block() }
+                .onSuccess { message -> host.emitEffect(ReadBookEffect.ShowToast(message)) }
+                .onFailure { throwable ->
+                    AppLog.put(logTag, throwable)
+                    host.emitEffect(
+                        ReadBookEffect.LongToast(
+                            throwable.localizedMessage ?: context.getString(R.string.error)
+                        )
+                    )
+                }
+        }
+    }
+
+    private fun queryDisplayName(uri: Uri): String? {
+        return context.contentResolver.query(
+            uri,
+            arrayOf(OpenableColumns.DISPLAY_NAME),
+            null,
+            null,
+            null
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) {
+                val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+                if (index >= 0) cursor.getString(index) else null
+            } else {
+                null
+            }
+        }
+    }
+
+    // --- 取色 ---
+
+    fun colorSelected(dialogId: Int, color: Int) {
+        when (dialogId) {
+            ReadBookColorPickerIds.SHADOW_COLOR -> {
+                readStyleGateway.updateCurrentStyle(colorMutation(ReadStyleColorKey.Shadow, color))
+                emitRepaintTextActions()
+            }
+
+            ReadBookColorPickerIds.TEXT_COLOR -> {
+                readStyleGateway.updateCurrentStyle(colorMutation(ReadStyleColorKey.Text, color))
+                emitRepaintTextActions()
+                postActionBarUpdateIfFollowingPage()
+            }
+
+            ReadBookColorPickerIds.TEXT_ACCENT_COLOR -> {
+                readStyleGateway.updateCurrentStyle(
+                    colorMutation(ReadStyleColorKey.TextAccent, color)
+                )
+                emitRepaintTextActions()
+                postActionBarUpdateIfFollowingPage()
+            }
+
+            ReadBookColorPickerIds.BG_COLOR -> {
+                readStyleGateway.updateCurrentStyle(
+                    ReadStyleMutation.Background(0, "#${color.hexString}")
+                )
+                emitConfigUpdate(ConfigUpdateAction.UpdateBackground)
+                postActionBarUpdateIfFollowingPage()
+            }
+
+            ReadBookColorPickerIds.TIP_HEADER_COLOR ->
+                updateTipColor(ReadStyleColorKey.TipHeader, color)
+
+            ReadBookColorPickerIds.TIP_FOOTER_COLOR ->
+                updateTipColor(ReadStyleColorKey.TipFooter, color)
+
+            ReadBookColorPickerIds.TIP_DIVIDER_COLOR ->
+                updateTipColor(ReadStyleColorKey.TipDivider, color)
+
+            ReadBookColorPickerIds.TITLE_COLOR -> {
+                readStyleGateway.updateCurrentStyle(colorMutation(ReadStyleColorKey.Title, color))
+                emitConfigUpdate(
+                    ConfigUpdateAction.UpdateChapterStyle,
+                    ConfigUpdateAction.ReloadContent,
+                )
+            }
+
+            ReadBookColorPickerIds.MENU_BG_COLOR -> {
+                scope.launch { readSettingsRepository.setReadMenuBgColor(color) }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+
+            ReadBookColorPickerIds.MENU_ACCENT_COLOR -> {
+                scope.launch { readSettingsRepository.setReadMenuAccentColor(color) }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+
+            ReadBookColorPickerIds.UNDERLINE_COLOR -> {
+                readStyleGateway.updateCurrentStyle(
+                    colorMutation(ReadStyleColorKey.Underline, color)
+                )
+                emitRepaintTextActions()
+            }
+        }
+    }
+
+    private fun updateTipColor(key: ReadStyleColorKey, color: Int) {
+        readStyleGateway.updateCurrentStyle(colorMutation(key, color))
+        postEvent(EventBus.TIP_COLOR, "")
+        emitConfigUpdate(ConfigUpdateAction.UpdateStyle)
+    }
+
+    /** 改颜色要重绘已排好的 TextPage,不只是换 paint。 */
+    private fun emitRepaintTextActions() = emitConfigUpdate(
+        ConfigUpdateAction.UpdateStyle,
+        ConfigUpdateAction.UpdateContent,
+        ConfigUpdateAction.InvalidateTextPage,
+        ConfigUpdateAction.SubmitRenderTask,
+    )
+
+    private fun postActionBarUpdateIfFollowingPage() {
+        if (readSettingsRepository.currentSettings.readBarStyleFollowPage) {
+            postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+        }
+    }
+
+    // --- 日夜 ---
+
+    fun toggleDayNight() {
+        lastSwitchDayNightReminderTime = System.currentTimeMillis()
+        resetDayNightReminderDismissal()
+        val nextMode = if (host.isNightTheme) "1" else "2"
+        scope.launch {
+            appShellSettingsGateway.update { it.copy(themeMode = nextMode) }
+        }
+        host.updateState {
+            val newActiveReminder = if (it.activeReminder?.type is ReminderType.DayNightReminder) {
+                null
+            } else {
+                it.activeReminder
+            }
+            it.copy(activeReminder = newActiveReminder)
+        }
+        // 排版值没变但解析后的生效值变了(颜色按模式取),经 gateway 统一重新发布,
+        // 由 collectReadStyle 重建 styleConfig + sheetConfig。
+        readStyleGateway.notifyModeChanged()
+        reminderQueue.removeAll { it.type is ReminderType.DayNightReminder }
+        emitConfigUpdate(
+            ConfigUpdateAction.UpdateBackground,
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+            ConfigUpdateAction.UpdateSystemUi,
+        )
+    }
+
+    fun resetDayNightReminderDismissal() {
+        hasDismissedDarkReminder = false
+        hasDismissedLightReminder = false
+    }
+
+    fun isDayNightSwitchCoolingDown(): Boolean =
+        System.currentTimeMillis() - lastSwitchDayNightReminderTime < REMINDER_COOLDOWN_MS
+
+    /**
+     * 环境光变化时判断要不要提示切换日夜。
+     * 只有「当前背景确实与目标模式相反」才提示——深色模式配浅色背景图时不该劝用户切回去。
+     */
+    fun checkSwitchDayNight(lux: Float) {
+        if (
+            !readSettingsRepository.currentSettings.autoSuggestDayNight ||
+            isDayNightSwitchCoolingDown()
+        ) return
+        val isNight = host.isNightTheme
+        val styleConfig = host.uiState.styleConfig
+        if (!isNight && lux <= DARK_LUX_THRESHOLD) {
+            if (hasDismissedDarkReminder) return
+            val bgType = styleConfig.bgType
+            val isLightBg = if (bgType == 0) {
+                val colorInt = runCatching { styleConfig.bgStr.toColorInt() }
+                    .getOrDefault(0xFFEEEEEE.toInt())
+                isReadBgLight(colorInt)
+            } else {
+                val meanColor = ReadSessionState.backgroundMeanColor
+                if (meanColor != 0) isReadBgLight(meanColor) else true
+            }
+            if (isLightBg) {
+                lastSwitchDayNightReminderTime = System.currentTimeMillis()
+                showReminder(
+                    ReminderUiState(
+                        message = context.getString(R.string.switch_to_dark_mode_tip),
+                        actionText = context.getString(R.string.switch_action),
+                        actionIntent = ReadBookIntent.ToggleDayNight,
+                        type = ReminderType.DayNightReminder(targetIsNight = true),
+                    )
+                )
+            }
+        } else if (isNight && lux >= BRIGHT_LUX_THRESHOLD) {
+            if (hasDismissedLightReminder) return
+            val bgTypeNight = styleConfig.bgTypeNight
+            val isDarkBg = if (bgTypeNight == 0) {
+                val colorInt = runCatching { styleConfig.bgStrNight.toColorInt() }
+                    .getOrDefault(0xFF000000.toInt())
+                !isReadBgLight(colorInt)
+            } else {
+                val meanColor = ReadSessionState.backgroundMeanColor
+                if (meanColor != 0) !isReadBgLight(meanColor) else true
+            }
+            if (isDarkBg) {
+                lastSwitchDayNightReminderTime = System.currentTimeMillis()
+                showReminder(
+                    ReminderUiState(
+                        message = context.getString(R.string.switch_to_light_mode_tip),
+                        actionText = context.getString(R.string.switch_action),
+                        actionIntent = ReadBookIntent.ToggleDayNight,
+                        type = ReminderType.DayNightReminder(targetIsNight = false),
+                    )
+                )
+            }
+        }
+    }
+
+    private fun isReadBgLight(colorInt: Int): Boolean {
+        // io.legado.app.utils.ColorUtils.isColorLight 判断条件是 >= 0.5
+        // 实际很多肉眼觉得亮的颜色会被判断为false,例如 0xFFC5B098
+        return AndroidColorUtils.calculateLuminance(colorInt) >= LIGHT_LUMINANCE_THRESHOLD
+    }
+
+    private fun showReminder(reminder: ReminderUiState) {
+        if (host.uiState.activeReminder == null && reminderQueue.isEmpty()) {
+            host.updateState { it.copy(activeReminder = reminder) }
+        } else {
+            reminderQueue.addLast(reminder)
+        }
+    }
+
+    fun dismissReminder() {
+        val currentReminder = host.uiState.activeReminder
+        if (currentReminder != null) {
+            when (val type = currentReminder.type) {
+                is ReminderType.DayNightReminder -> {
+                    if (type.targetIsNight) {
+                        hasDismissedDarkReminder = true
+                    } else {
+                        hasDismissedLightReminder = true
+                    }
+                }
+                else -> {}
+            }
+        }
+        host.updateState { it.copy(activeReminder = null) }
+        if (reminderQueue.isNotEmpty()) {
+            scope.launch {
+                //延迟一下,让上一个提醒的动画结束
+                delay(500.milliseconds)
+                if (host.uiState.activeReminder == null && reminderQueue.isNotEmpty()) {
+                    val next = reminderQueue.removeFirst()
+                    host.updateState { it.copy(activeReminder = next) }
+                }
+            }
+        }
+    }
+
+    // --- 护眼 ---
+
+    fun collectEyeProtectionSettings() {
+        scope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                host.updateState {
+                    it.copy(
+                        eyeProtection = EyeProtectionUiState(
+                            enabled = settings.eyeProtectionEnabled,
+                            intensity = settings.colorTemperature,
+                            autoNight = settings.eyeProtectionAutoNight,
+                            schedule = settings.eyeProtectionSchedule,
+                            startTime = settings.eyeProtectionStartTime,
+                            endTime = settings.eyeProtectionEndTime,
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    fun toggleEyeProtection() = updateEyeProtection {
+        if (it.isEyeProtectionConfigured) {
+            it.copy(eyeProtectionEnabled = false, eyeProtectionAutoNight = false)
+        } else {
+            it.copy(eyeProtectionEnabled = true)
+        }
+    }
+
+    fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {
+        scope.launch { themeSettingsGateway.update(transform) }
+    }
+
+    private fun emitConfigUpdate(vararg actions: ConfigUpdateAction) {
+        host.emitEffect(ReadBookEffect.UpdateReadViewConfig(actions.toSet()))
+    }
+
+    private companion object {
+        const val DARK_LUX_THRESHOLD = 8f
+        const val BRIGHT_LUX_THRESHOLD = 100f
+        const val LIGHT_LUMINANCE_THRESHOLD = 0.35
+        const val REMINDER_COOLDOWN_MS = 10 * 60 * 1000L
+    }
+}

+ 4 - 12
app/src/test/java/io/legado/app/ui/book/read/ReadBookCallbackBoundaryTest.kt

@@ -23,13 +23,12 @@ class ReadBookCallbackBoundaryTest {
         val source = stripComments(
         val source = stripComments(
             mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
             mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
         )
         )
+        // 只查超类型和注册调用,不查 `override fun upMenuView` 之类的方法名:
+        // Kotlin 里没有超类型就写不出 override,方法名检查是多余的;而各 delegate 的
+        // Host 完全可以有同名方法(VM 确实实现了 ReadBookLoadDelegate.Host.sureNewProgress),
+        // 查方法名会把这种正当写法误报成回归。
         val violations = buildList {
         val violations = buildList {
             if (CALLBACK_SUPERTYPE.containsMatchIn(source)) add("声明了 ReadBook.CallBack 超类型")
             if (CALLBACK_SUPERTYPE.containsMatchIn(source)) add("声明了 ReadBook.CallBack 超类型")
-            CALLBACK_METHODS.forEach { method ->
-                if (Regex("""\boverride\s+fun\s+$method\s*\(""").containsMatchIn(source)) {
-                    add("override fun $method")
-                }
-            }
             if (Regex("""\bReadBook\.(?:register|unregister)\s*\(\s*this\s*\)""")
             if (Regex("""\bReadBook\.(?:register|unregister)\s*\(\s*this\s*\)""")
                     .containsMatchIn(source)
                     .containsMatchIn(source)
             ) {
             ) {
@@ -63,13 +62,6 @@ class ReadBookCallbackBoundaryTest {
     }
     }
 
 
     private companion object {
     private companion object {
-        val CALLBACK_METHODS = listOf(
-            "upMenuView",
-            "loadChapterList",
-            "notifyBookChanged",
-            "sureNewProgress",
-        )
-
         val CONTROLLER_LOCAL_EFFECTS = listOf(
         val CONTROLLER_LOCAL_EFFECTS = listOf(
             "PageChanged",
             "PageChanged",
             "ContentLoadFinish",
             "ContentLoadFinish",

+ 61 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -59,6 +59,22 @@ class ReadBookDomainSplitBoundaryTest {
         }
         }
     }
     }
 
 
+    /**
+     * R2 的终态验收线。不是为了追行数好看——超过这个数就说明又有新的域直接长在 VM 里,
+     * 而不是长成一个 delegate。要放宽必须先说明新增的是哪个域、为什么不能摘。
+     */
+    @Test
+    fun `ReadBookViewModel 不超过 R2 验收的 2500 行`() {
+        val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
+            .readLines().size
+        assertTrue(
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2500。\n" +
+                "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
+                "并在本测试的 DOMAINS 里加一条边界。",
+            lineCount <= 2500,
+        )
+    }
+
     @Test
     @Test
     fun `各 delegate 不自带 DAO 直连`() {
     fun `各 delegate 不自带 DAO 直连`() {
         DOMAINS.forEach { domain ->
         DOMAINS.forEach { domain ->
@@ -159,6 +175,51 @@ class ReadBookDomainSplitBoundaryTest {
                 stateFields = setOf("contentProcessConfig"),
                 stateFields = setOf("contentProcessConfig"),
                 stateTypes = listOf("ContentProcessConfigUiState", "ContentProcessItemUi"),
                 stateTypes = listOf("ContentProcessConfigUiState", "ContentProcessItemUi"),
             ),
             ),
+            // 开书域无自持状态:isInitFinish 是 ReadView 首帧的放行门闩,必须留在 UiState
+            DomainSplit(
+                name = "开书/换源",
+                delegateFile = "io/legado/app/ui/book/read/ReadBookLoadDelegate.kt",
+                stateFields = emptySet(),
+                // 用「调用点」而不是「依赖名」当标记:依赖名在 VM 的 delegate 装配处
+                // 本来就会出现,那是正当接线,不是逻辑回流。
+                stateTypes = listOf(
+                    "changeBookSourceUseCase.changeTo",
+                    "WebBook.getChapterListAwait",
+                    "uploadReadingProgressUseCase.execute",
+                ),
+            ),
+            DomainSplit(
+                name = "书签",
+                delegateFile = "io/legado/app/ui/book/read/ReadBookmarkDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf("bookmarkRepository.save", "bookmarkRepository.delete"),
+            ),
+            // 样式域无自持状态:styleConfig 的重建由 VM 的 collectReadStyle() 统一驱动,
+            // activeReminder / eyeProtection 被菜单栏直读;靠 stateTypes 守
+            // 「取色、日夜提醒判定、样式导入导出不回流 VM」
+            DomainSplit(
+                name = "阅读样式",
+                delegateFile = "io/legado/app/ui/book/read/ReadStyleDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "ReadBookColorPickerIds",
+                    "ReminderType.DayNightReminder",
+                    "importCurrentStyle",
+                    "saveBackgroundImage",
+                ),
+            ),
+            // 朗读域无自持状态:20 来个朗读字段被四个 composable 直读,搬出去要改四处入参;
+            // 靠 stateTypes 守「设置写入与合成管线重启逻辑不回流 VM」
+            DomainSplit(
+                name = "朗读",
+                delegateFile = "io/legado/app/ui/book/read/ReadAloudDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "readAloudSettingsRepository.update",
+                    "VoiceCatalogEntry",
+                    "refreshReadAloudClass",
+                ),
+            ),
             // 按钮配置域无自持状态:按钮列表仍在 menuConfig 里,靠 stateTypes 守
             // 按钮配置域无自持状态:按钮列表仍在 menuConfig 里,靠 stateTypes 守
             // 「SharedPreferences 读写和归一化逻辑不回流 VM」
             // 「SharedPreferences 读写和归一化逻辑不回流 VM」
             DomainSplit(
             DomainSplit(

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.