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

Merge pull request #1904 from 5151561/main

refactor: 阅读器核心 MAD 收敛 + 全应用 UI→DAO 直连清零
Kudomaga 3 месяцев назад
Родитель
Сommit
a8738fa2fd
100 измененных файлов с 11199 добавлено и 2003 удалено
  1. 76 0
      .github/workflows/verify.yml
  2. 15 0
      app/build.gradle.kts
  3. 2928 0
      app/lint-baseline.xml
  4. 3 3
      app/src/main/java/io/legado/app/App.kt
  5. 0 1
      app/src/main/java/io/legado/app/constant/EventBus.kt
  6. 36 0
      app/src/main/java/io/legado/app/constant/ReadTipType.kt
  7. 16 0
      app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt
  8. 2 2
      app/src/main/java/io/legado/app/data/entities/Book.kt
  9. 45 0
      app/src/main/java/io/legado/app/data/repository/BookCacheManageRepository.kt
  10. 8 0
      app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt
  11. 36 0
      app/src/main/java/io/legado/app/data/repository/BookImportRepository.kt
  12. 63 1
      app/src/main/java/io/legado/app/data/repository/BookRepository.kt
  13. 157 0
      app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt
  14. 39 0
      app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt
  15. 4 4
      app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt
  16. 4 0
      app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt
  17. 31 0
      app/src/main/java/io/legado/app/data/repository/HttpTtsRepository.kt
  18. 163 103
      app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt
  19. 73 9
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  20. 139 0
      app/src/main/java/io/legado/app/data/repository/ReadStyleConfigStore.kt
  21. 65 81
      app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt
  22. 54 40
      app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt
  23. 33 0
      app/src/main/java/io/legado/app/data/repository/RssArticleRepository.kt
  24. 6 0
      app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt
  25. 23 0
      app/src/main/java/io/legado/app/data/repository/RssReadRecordRepository.kt
  26. 86 5
      app/src/main/java/io/legado/app/data/repository/RssRepository.kt
  27. 34 0
      app/src/main/java/io/legado/app/data/repository/RssSourceEditRepository.kt
  28. 53 0
      app/src/main/java/io/legado/app/data/repository/SearchRepository.kt
  29. 4 0
      app/src/main/java/io/legado/app/data/repository/TagGroupRuleRepository.kt
  30. 9 4
      app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt
  31. 81 2
      app/src/main/java/io/legado/app/di/appModule.kt
  32. 5 3
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  33. 19 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleGateway.kt
  34. 9 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt
  35. 249 626
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  36. 6 17
      app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt
  37. 5 6
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  38. 1 1
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  39. 7 8
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  40. 4 3
      app/src/main/java/io/legado/app/model/FullBookPaginator.kt
  41. 187 35
      app/src/main/java/io/legado/app/model/ReadBook.kt
  42. 138 0
      app/src/main/java/io/legado/app/model/ReaderSession.kt
  43. 3 0
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  44. 2 2
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt
  45. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt
  46. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportDictRuleDialog.kt
  47. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportDictRuleViewModel.kt
  48. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportHttpTtsDialog.kt
  49. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportHttpTtsViewModel.kt
  50. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt
  51. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt
  52. 2 2
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt
  53. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt
  54. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleDialog.kt
  55. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleViewModel.kt
  56. 2 2
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  57. 16 13
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt
  58. 6 6
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt
  59. 40 37
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  60. 2 2
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt
  61. 10 5
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
  62. 15 16
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt
  63. 3 3
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt
  64. 28 44
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
  65. 10 18
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt
  66. 13 19
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  67. 9 8
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  68. 46 39
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt
  70. 9 6
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt
  71. 21 18
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  72. 91 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAiContract.kt
  73. 1352 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt
  74. 438 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  75. 34 137
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  76. 192 33
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  77. 419 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  78. 54 37
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  79. 11 7
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  80. 505 332
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  81. 68 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  82. 159 0
      app/src/main/java/io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt
  83. 21 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateBus.kt
  84. 842 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  85. 18 0
      app/src/main/java/io/legado/app/ui/book/read/ReadContentEditContract.kt
  86. 195 0
      app/src/main/java/io/legado/app/ui/book/read/ReadContentEditDelegate.kt
  87. 140 0
      app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt
  88. 20 0
      app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleContract.kt
  89. 351 0
      app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt
  90. 144 0
      app/src/main/java/io/legado/app/ui/book/read/ReadSearchResultLocator.kt
  91. 498 0
      app/src/main/java/io/legado/app/ui/book/read/ReadStyleDelegate.kt
  92. 25 0
      app/src/main/java/io/legado/app/ui/book/read/ReaderFirstFrame.kt
  93. 118 0
      app/src/main/java/io/legado/app/ui/book/read/ReaderViewport.kt
  94. 14 2
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  95. 11 8
      app/src/main/java/io/legado/app/ui/book/read/page/CustomTipPageRemaining.kt
  96. 130 150
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  97. 46 60
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  98. 43 0
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt
  99. 47 0
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderPageSource.kt
  100. 1 2
      app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt

+ 76 - 0
.github/workflows/verify.yml

@@ -0,0 +1,76 @@
+name: Verify
+
+on:
+  push:
+    branches:
+      - main
+      - master
+    paths-ignore:
+      - '**/modules/web/**'
+  pull_request:
+    paths-ignore:
+      - '**/modules/web/**'
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: verify-${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  verify:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v5
+
+      - name: Set up JDK 21
+        uses: actions/setup-java@v5
+        with:
+          distribution: temurin
+          java-version: 21
+
+      - name: Set up Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      # 与 auto-release.yml 同一处理:settings.gradle 里的 aliyun 镜像面向国内开发机,
+      # 在 GitHub runner 上会 502,且 Gradle 一旦判定某仓库失败就整个禁用它。
+      - name: Remove Aliyun mirrors (unreliable in CI)
+        run: |
+          sed -i '/maven.aliyun.com/d' settings.gradle
+
+      - name: Run tests, lint, architecture check, and debug build
+        run: |
+          chmod +x gradlew
+          ./gradlew \
+            testAppDebugUnitTest \
+            lintAppDebug \
+            verifyConfigArchitecture \
+            assembleAppDebug \
+            --continue \
+            --no-daemon \
+            --no-configuration-cache
+
+      - name: Upload unit test reports
+        if: ${{ always() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: verify-unit-test-reports
+          if-no-files-found: warn
+          path: |
+            app/build/test-results/testAppDebugUnitTest/**
+            app/build/reports/tests/testAppDebugUnitTest/**
+
+      - name: Upload lint reports
+        if: ${{ always() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: verify-lint-reports
+          if-no-files-found: warn
+          path: |
+            app/build/reports/lint-results-appDebug.html
+            app/build/reports/lint-results-appDebug.xml
+            app/build/reports/lint-results-appDebug.txt
+            app/build/reports/lint-results-appDebug.sarif

+ 15 - 0
app/build.gradle.kts

@@ -151,7 +151,22 @@ android {
     }
 
     lint {
+        baseline = file("lint-baseline.xml")
         checkDependencies = true
+        // UnusedResources 在基线里占 1223/1621 条、12218 行(全文件的 75%),几乎全是
+        // 跟随上游时留下的资源;release 本来就开了精确资源压缩,它们不会进 APK。
+        // 关掉它让基线只剩真正值得盯的那部分,新增问题照样报红。
+        disable += "UnusedResources"
+    }
+
+    testOptions {
+        unitTests {
+            // 阅读器核心在构造期就读字符串资源(TextPageFactory 的 keepSwipeTip、
+            // TextPage 的默认 text/title、ReadView 的无障碍动作名)。不打开这个,
+            // Robolectric 下取任何 R.string 都是 Resources$NotFoundException,
+            // 整条阅读器测试线(Track D·D1c)就起不来。
+            isIncludeAndroidResources = true
+        }
     }
 }
 

Разница между файлами не показана из-за своего большого размера
+ 2928 - 0
app/lint-baseline.xml


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

@@ -38,6 +38,7 @@ import io.legado.app.di.appModule
 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.domain.gateway.ReadStyleGateway
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
@@ -120,10 +121,9 @@ class App : Application(), ImageLoaderFactory {
             exportGateway = get(),
         )
         ReadBookConfig.initialize(
-            readStyleRepository = get(),
+            configStore = get(),
             readSettingsGateway = get(),
         )
-        ReadBookConfig.attachGateway(get())
         if (legacyLanguage != null) {
             get<AppLocaleGateway>().migrateLegacyLanguage(legacyLanguage)
         }
@@ -215,7 +215,7 @@ class App : Application(), ImageLoaderFactory {
             RuleBigDataHelp.clearInvalid()
             BookHelp.clearInvalidCache()
             Backup.clearCache()
-            ReadBookConfig.clearBgAndCache()
+            get<ReadStyleGateway>().clearUnusedBackgrounds()
             ThemeConfigStore.clearBg()
             //初始化简繁转换引擎
             when (AppConfig.chineseConverterType) {

+ 0 - 1
app/src/main/java/io/legado/app/constant/EventBus.kt

@@ -10,7 +10,6 @@ object EventBus {
     const val READ_ALOUD_DS = "readAloudDs"
     const val BATTERY_CHANGED = "batteryChanged"
     const val TIME_CHANGED = "timeChanged"
-    const val UP_CONFIG = "upConfig"
     const val AUDIO_SUB_TITLE = "audioSubTitle"
     const val AUDIO_STATE = "audioState"
     const val AUDIO_PROGRESS = "audioProgress"

+ 36 - 0
app/src/main/java/io/legado/app/constant/ReadTipType.kt

@@ -0,0 +1,36 @@
+package io.legado.app.constant
+
+/**
+ * 页眉/页脚每个位置可以显示的信息类型。
+ *
+ * 这批值是编译期常量、不是配置状态——它们过去挂在 `ReadBookConfig` 上,导致
+ * 「渲染层还直读多少配置」这类统计和护栏没法写:`PageView` 里 70 处 `ReadBookConfig.`
+ * 有 41 处其实只是引用这些常量。
+ *
+ * 真正的配置项是「哪个位置放哪个类型」,即 `ReadBookConfig.tipHeaderLeft` 等六项,
+ * 它们的取值来自这里。
+ */
+@Suppress("ConstPropertyName")
+object ReadTipType {
+    const val tipNone = 0
+    const val tipChapterTitle = 1
+    const val tipTime = 2
+    const val tipBattery = 3
+    const val tipPage = 4
+    const val tipTotalProgress = 5
+    const val tipPageAndTotal = 6
+    const val tipBookName = 7
+    const val tipTimeBattery = 8
+    const val tipTimeBatteryPercentage = 9
+    const val tipBatteryPercentage = 10
+    const val tipTotalProgress1 = 11
+    const val tipChapterTitleArrow = 12
+    const val tipBatteryInside = 13
+    const val tipBatteryIcon = 14
+    const val tipBatteryClassic = 15
+    const val tipTimeBatteryClassic = 16
+    const val tipChapterTitleArrowClassic = 17
+    const val tipCustom = 18
+    const val tipWholeBookPage = 19
+    const val tipWholeBookPageAndProgress = 20
+}

+ 16 - 0
app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt

@@ -311,6 +311,22 @@ interface BookSourceDao {
     @get:Query("select max(customOrder) from book_sources")
     val maxOrder: Int
 
+    @Transaction
+    fun moveToTop(sourceUrl: String): Int? {
+        val source = getBookSource(sourceUrl) ?: return null
+        source.customOrder = minOrder - 1
+        update(source)
+        return source.customOrder
+    }
+
+    @Transaction
+    fun moveToBottom(sourceUrl: String): Int? {
+        val source = getBookSource(sourceUrl) ?: return null
+        source.customOrder = maxOrder + 1
+        update(source)
+        return source.customOrder
+    }
+
     @get:Query(
         """select exists (select 1 
         from book_sources group by customOrder having count(customOrder) > 1)"""

+ 2 - 2
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -402,8 +402,8 @@ data class Book(
     }
 
     fun delete() {
-        if (ReadBook.book?.bookUrl == bookUrl) {
-            ReadBook.book = null
+        if (ReadBook.isCurrentBook(bookUrl)) {
+            ReadBook.clearCurrentBook()
         }
         appDb.bookChapterDao.delByBook(bookUrl)
         appDb.bookDao.delete(this)

+ 45 - 0
app/src/main/java/io/legado/app/data/repository/BookCacheManageRepository.kt

@@ -0,0 +1,45 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookGroupDao
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.model.BookChapterCacheInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class BookCacheManageRepository(
+    private val bookDao: BookDao,
+    private val bookChapterDao: BookChapterDao,
+    private val bookGroupDao: BookGroupDao,
+) {
+
+    fun flowBooks(): Flow<List<Book>> = bookDao.flowAll().flowOn(Dispatchers.IO)
+
+    fun flowSelectableGroups(): Flow<List<BookGroup>> =
+        bookGroupDao.flowSelect().flowOn(Dispatchers.IO)
+
+    suspend fun getAllBooks(): List<Book> = withContext(Dispatchers.IO) {
+        bookDao.all
+    }
+
+    suspend fun getBook(bookUrl: String): Book? = withContext(Dispatchers.IO) {
+        bookDao.getBook(bookUrl)
+    }
+
+    suspend fun getChapterCount(bookUrl: String): Int = withContext(Dispatchers.IO) {
+        bookChapterDao.getChapterCount(bookUrl)
+    }
+
+    suspend fun getVolumeCount(bookUrl: String): Int = withContext(Dispatchers.IO) {
+        bookChapterDao.getVolumeCount(bookUrl)
+    }
+
+    suspend fun getChapterCacheInfo(bookUrl: String): List<BookChapterCacheInfo> =
+        withContext(Dispatchers.IO) {
+            bookChapterDao.getChapterCacheInfoList(bookUrl)
+        }
+}

+ 8 - 0
app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt

@@ -42,6 +42,14 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) {
         return bookGroupDao.getByID(id)
     }
 
+    suspend fun getIdsSum(): Long {
+        return bookGroupDao.idsSum
+    }
+
+    suspend fun getGroupNames(id: Long): List<String> {
+        return bookGroupDao.getGroupNames(id)
+    }
+
     suspend fun clearCover(groupId: Long) {
         bookGroupDao.clearCover(groupId)
     }

+ 36 - 0
app/src/main/java/io/legado/app/data/repository/BookImportRepository.kt

@@ -0,0 +1,36 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.canSafelyRebindTo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class BookImportRepository(
+    private val appDb: AppDatabase,
+) {
+
+    fun flowLocalBooks(): Flow<List<Book>> = appDb.bookDao.flowLocal().flowOn(Dispatchers.IO)
+
+    suspend fun findByFileName(fileName: String): Book? = withContext(Dispatchers.IO) {
+        appDb.bookDao.getBookByFileName(fileName)
+    }
+
+    suspend fun findAndRebind(fileName: String, filePath: String): Book? =
+        withContext(Dispatchers.IO) {
+            val book = appDb.bookDao.getBookByFileName(fileName) ?: return@withContext null
+            if (book.bookUrl == filePath || !book.canSafelyRebindTo(filePath)) {
+                return@withContext book
+            }
+
+            val reboundBook = book.copy(bookUrl = filePath)
+            appDb.runInTransaction {
+                appDb.bookDao.replace(book, reboundBook)
+                BookHelp.updateCacheFolder(book, reboundBook)
+            }
+            reboundBook
+        }
+}

+ 63 - 1
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.repository
 
+import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.GroupBookCount
@@ -12,8 +13,17 @@ import kotlinx.coroutines.withContext
 
 class BookRepository(
     private val bookDao: BookDao,
-    private val bookChapterDao: BookChapterDao
+    private val bookChapterDao: BookChapterDao,
+    private val appDb: AppDatabase,
 ) {
+    fun flowBook(bookUrl: String): Flow<Book?> {
+        return bookDao.flowGetBook(bookUrl)
+    }
+
+    fun flowChapters(bookUrl: String): Flow<List<BookChapter>> {
+        return bookChapterDao.getChapterListFlow(bookUrl)
+    }
+
     fun getAllBooks(): Flow<List<Book>> {
         return bookDao.flowAll()
     }
@@ -46,6 +56,12 @@ class BookRepository(
         }
     }
 
+    suspend fun getShelfBookConflict(name: String, author: String): Book? {
+        return withContext(Dispatchers.IO) {
+            bookDao.getShelfBookConflict(name, author)
+        }
+    }
+
     fun flowBookShelfByGroup(groupId: Long): Flow<List<BookShelfItem>> {
         return bookDao.flowBookShelfByGroup(groupId)
     }
@@ -66,6 +82,12 @@ class BookRepository(
         return bookDao.flowGroupPreview(groupId)
     }
 
+    suspend fun getChapter(bookUrl: String, index: Int): BookChapter? {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getChapter(bookUrl, index)
+        }
+    }
+
     suspend fun getChapterCount(bookUrl: String): Int {
         return withContext(Dispatchers.IO) {
             bookChapterDao.getChapterCount(bookUrl)
@@ -78,6 +100,18 @@ class BookRepository(
         }
     }
 
+    suspend fun getChapters(bookUrl: String): List<BookChapter> {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getChapterList(bookUrl)
+        }
+    }
+
+    suspend fun getChapters(bookUrl: String, start: Int, end: Int): List<BookChapter> {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getChapterList(bookUrl, start, end)
+        }
+    }
+
     suspend fun update(vararg book: Book) {
         withContext(Dispatchers.IO) {
             bookDao.update(*book)
@@ -108,16 +142,44 @@ class BookRepository(
         }
     }
 
+    suspend fun getAll(): List<Book> {
+        return withContext(Dispatchers.IO) {
+            bookDao.getAll()
+        }
+    }
+
+    suspend fun getLastReadBook(): Book? {
+        return withContext(Dispatchers.IO) {
+            bookDao.lastReadBook
+        }
+    }
+
     suspend fun replace(oldBook: Book, newBook: Book) {
         withContext(Dispatchers.IO) {
             bookDao.replace(oldBook, newBook)
         }
     }
 
+    suspend fun delete(vararg book: Book) {
+        withContext(Dispatchers.IO) {
+            bookDao.delete(*book)
+        }
+    }
+
     suspend fun deleteChaptersByBook(bookUrl: String) {
         withContext(Dispatchers.IO) {
             bookChapterDao.delByBook(bookUrl)
         }
     }
 
+    suspend fun replaceChaptersAndUpdateBook(book: Book, chapters: List<BookChapter>) {
+        withContext(Dispatchers.IO) {
+            appDb.runInTransaction {
+                bookChapterDao.delByBook(book.bookUrl)
+                bookChapterDao.insert(*chapters.toTypedArray())
+                bookDao.update(book)
+            }
+        }
+    }
+
 }

+ 157 - 0
app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt

@@ -3,6 +3,7 @@ package io.legado.app.data.repository
 import io.legado.app.data.dao.BookSourceDao
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.help.source.SourceHelp
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
@@ -17,6 +18,10 @@ class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
         return bookSourceDao.flowEnabled()
     }
 
+    fun flowGroups(): Flow<List<String>> {
+        return bookSourceDao.flowGroups()
+    }
+
     fun flowHomepageModules(): Flow<List<BookSource>> {
         return bookSourceDao.flowHomepageModules()
     }
@@ -35,10 +40,20 @@ class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
         }
     }
 
+    suspend fun getBookSourcePart(sourceUrl: String): BookSourcePart? {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.getBookSourcePart(sourceUrl)
+        }
+    }
+
     fun getBookSourceSync(sourceUrl: String): BookSource? {
         return bookSourceDao.getBookSource(sourceUrl)
     }
 
+    fun has(bookSourceUrl: String): Boolean {
+        return bookSourceDao.has(bookSourceUrl)
+    }
+
     suspend fun getBookSourceAddBook(baseUrl: String): BookSource? {
         return withContext(Dispatchers.IO) {
             bookSourceDao.getBookSourceAddBook(baseUrl)
@@ -56,4 +71,146 @@ class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
             bookSourceDao.allEnabledPart
         }
     }
+
+    suspend fun getAllPart(): List<BookSourcePart> {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.allPart
+        }
+    }
+
+    suspend fun moveToEdge(sources: List<BookSourcePart>, toTop: Boolean) =
+        withContext(Dispatchers.IO) {
+            val selected = sources.sortedBy { it.customOrder }
+            val start = if (toTop) {
+                bookSourceDao.minOrder - selected.size
+            } else {
+                bookSourceDao.maxOrder + 1
+            }
+            bookSourceDao.upOrder(
+                selected.mapIndexed { index, part -> part.copy(customOrder = start + index) }
+            )
+        }
+
+    suspend fun getAllTextEnabledPart(): List<BookSourcePart> {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.allTextEnabledPart
+        }
+    }
+
+    suspend fun topSources(sources: List<BookSourcePart>) = withContext(Dispatchers.IO) {
+        val minOrder = bookSourceDao.minOrder - 1
+        val reorderedSources = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
+            source.copy(customOrder = minOrder - index)
+        }
+        bookSourceDao.upOrder(reorderedSources)
+    }
+
+    suspend fun bottomSources(sources: List<BookSourcePart>) = withContext(Dispatchers.IO) {
+        val maxOrder = bookSourceDao.maxOrder + 1
+        val reorderedSources = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
+            source.copy(customOrder = maxOrder + index)
+        }
+        bookSourceDao.upOrder(reorderedSources)
+    }
+
+    suspend fun deleteSourceParts(sources: List<BookSourcePart>) = withContext(Dispatchers.IO) {
+        SourceHelp.deleteBookSourceParts(sources)
+    }
+
+    suspend fun deleteSource(sourceUrl: String) = withContext(Dispatchers.IO) {
+        SourceHelp.deleteBookSource(sourceUrl)
+    }
+
+    suspend fun disableSource(sourceUrl: String) = withContext(Dispatchers.IO) {
+        bookSourceDao.getBookSource(sourceUrl)?.let { source ->
+            source.enabled = false
+            bookSourceDao.update(source)
+        }
+    }
+
+    suspend fun moveSourceToTop(sourceUrl: String): Int? = withContext(Dispatchers.IO) {
+        bookSourceDao.moveToTop(sourceUrl)
+    }
+
+    suspend fun moveSourceToBottom(sourceUrl: String): Int? = withContext(Dispatchers.IO) {
+        bookSourceDao.moveToBottom(sourceUrl)
+    }
+
+    suspend fun updateSources(vararg sources: BookSource) = withContext(Dispatchers.IO) {
+        bookSourceDao.update(*sources)
+    }
+
+    suspend fun updateOrder(sources: List<BookSourcePart>) = withContext(Dispatchers.IO) {
+        bookSourceDao.upOrder(sources)
+    }
+
+    suspend fun setEnabled(enabled: Boolean, sources: List<BookSourcePart>) =
+        withContext(Dispatchers.IO) {
+            bookSourceDao.enable(enabled, sources)
+        }
+
+    suspend fun setEnabled(sourceUrl: String, enabled: Boolean) = withContext(Dispatchers.IO) {
+        bookSourceDao.enable(sourceUrl, enabled)
+    }
+
+    suspend fun setExploreEnabled(enabled: Boolean, sources: List<BookSourcePart>) =
+        withContext(Dispatchers.IO) {
+            bookSourceDao.enableExplore(enabled, sources)
+        }
+
+    suspend fun updateGroups(sources: List<BookSourcePart>) = withContext(Dispatchers.IO) {
+        bookSourceDao.upGroup(sources)
+    }
+
+    suspend fun getAll(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.all
+    }
+
+    suspend fun getAllEnabled(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allEnabled
+    }
+
+    suspend fun getAllDisabled(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allDisabled
+    }
+
+    suspend fun getAllLogin(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allLogin
+    }
+
+    suspend fun getAllNoGroup(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allNoGroup
+    }
+
+    suspend fun getAllExploreEnabled(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allEnabledExplore
+    }
+
+    suspend fun getAllExploreDisabled(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.allDisabledExplore
+    }
+
+    suspend fun getByGroup(group: String): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.getByGroup(group)
+    }
+
+    suspend fun searchByGroup(group: String): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.groupSearch(group)
+    }
+
+    suspend fun search(searchKey: String): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.search(searchKey)
+    }
+
+    suspend fun getNoGroup(): List<BookSource> = withContext(Dispatchers.IO) {
+        bookSourceDao.noGroup
+    }
+
+    suspend fun delete(source: BookSource) = withContext(Dispatchers.IO) {
+        bookSourceDao.delete(source)
+    }
+
+    suspend fun insert(source: BookSource) = withContext(Dispatchers.IO) {
+        bookSourceDao.insert(source)
+    }
 }

+ 39 - 0
app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt

@@ -0,0 +1,39 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookmarkDao
+import io.legado.app.data.entities.Bookmark
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class BookmarkRepository(
+    private val dao: BookmarkDao,
+) {
+
+    fun flowAll(): Flow<List<Bookmark>> = dao.flowAll().flowOn(Dispatchers.IO)
+
+    fun flowByBook(bookName: String, bookAuthor: String): Flow<List<Bookmark>> =
+        dao.flowByBook(bookName, bookAuthor).flowOn(Dispatchers.IO)
+
+    suspend fun getAll(): List<Bookmark> = withContext(Dispatchers.IO) {
+        dao.all
+    }
+
+    suspend fun getByBook(bookName: String, bookAuthor: String): List<Bookmark> =
+        withContext(Dispatchers.IO) {
+            dao.getByBook(bookName, bookAuthor)
+        }
+
+    suspend fun save(bookmark: Bookmark) = withContext(Dispatchers.IO) {
+        dao.insert(bookmark)
+    }
+
+    suspend fun saveAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
+        dao.insert(*bookmarks.toTypedArray())
+    }
+
+    suspend fun delete(bookmark: Bookmark) = withContext(Dispatchers.IO) {
+        dao.delete(bookmark)
+    }
+}

+ 4 - 4
app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt

@@ -1,14 +1,14 @@
 package io.legado.app.data.repository
 
-import io.legado.app.data.appDb
+import io.legado.app.data.dao.DictRuleDao
 import io.legado.app.data.entities.DictRule
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
 
-class DictRuleRepository {
-
-    private val dao = appDb.dictRuleDao
+class DictRuleRepository(
+    private val dao: DictRuleDao,
+) {
 
     fun flowAll(): Flow<List<DictRule>> {
         return dao.flowAll()

+ 4 - 0
app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt

@@ -14,6 +14,10 @@ class HighlightTagRuleRepository {
         return dao.flowAll()
     }
 
+    suspend fun getEnabled(): List<HighlightTagRule> = withContext(Dispatchers.IO) {
+        dao.getEnabled()
+    }
+
     suspend fun insert(vararg rule: HighlightTagRule) {
         withContext(Dispatchers.IO) {
             dao.insert(*rule)

+ 31 - 0
app/src/main/java/io/legado/app/data/repository/HttpTtsRepository.kt

@@ -0,0 +1,31 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.HttpTTSDao
+import io.legado.app.data.entities.HttpTTS
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class HttpTtsRepository(
+    private val dao: HttpTTSDao,
+) {
+
+    suspend fun findById(id: Long): HttpTTS? = withContext(Dispatchers.IO) {
+        dao.get(id)
+    }
+
+    suspend fun getAll(): List<HttpTTS> = withContext(Dispatchers.IO) {
+        dao.all
+    }
+
+    fun getAllSync(): List<HttpTTS> = dao.all
+
+    fun getNameSync(id: Long): String? = dao.getName(id)
+
+    suspend fun insert(vararg sources: HttpTTS) = withContext(Dispatchers.IO) {
+        dao.insert(*sources)
+    }
+
+    suspend fun delete(vararg sources: HttpTTS) = withContext(Dispatchers.IO) {
+        dao.delete(*sources)
+    }
+}

+ 163 - 103
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -26,10 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import java.io.InputStream
+import java.util.concurrent.atomic.AtomicLong
 
 class ReadBookStyleConfigRepository(
     private val readStyleRepository: ReadStyleRepository,
     private val highlightRuleRepository: HighlightRuleRepository,
+    private val configStore: ReadStyleConfigStore,
 ) : ReadStyleGateway {
     private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
     private val saveQueue = ReadStyleSaveQueue(
@@ -41,13 +43,18 @@ class ReadBookStyleConfigRepository(
             AppLog.put("保存排版配置文件出错", error)
         },
     )
+    private val stateRevision = AtomicLong(0L)
     private val _state = MutableStateFlow(buildState())
     override val state: StateFlow<ReadStyleState> = _state.asStateFlow()
     override val currentState: ReadStyleState get() = _state.value
 
     override fun refresh() {
-        ReadBookConfig.initConfigs()
-        ReadBookConfig.initShareConfig()
+        configStore.initConfigs()
+        configStore.initShareConfig()
+        publishState()
+    }
+
+    override fun notifyModeChanged() {
         publishState()
     }
 
@@ -55,8 +62,8 @@ class ReadBookStyleConfigRepository(
         publishState()
         saveQueue.submit(
             ReadStyleSaveSnapshot(
-                configs = ReadBookConfig.configsSnapshot(),
-                shareConfig = ReadBookConfig.shareConfigSnapshot(),
+                configs = configStore.configsSnapshot(),
+                shareConfig = configStore.shareConfigSnapshot(),
             )
         )
     }
@@ -69,11 +76,16 @@ class ReadBookStyleConfigRepository(
             is ReadStyleMutation.StringValue -> updateString(mutation.key, mutation.value)
             is ReadStyleMutation.ColorValue -> updateColor(mutation.key, mutation.value)
             is ReadStyleMutation.Background ->
-                ReadBookConfig.durConfig.setCurBg(mutation.type, mutation.value)
+                mutateCurrentStyle { it.withCurBg(mutation.type, mutation.value) }
         }
         publishState()
     }
 
+    override fun clearMissingTextFont() {
+        updateCurrentStyle(ReadStyleMutation.StringValue(ReadStyleStringKey.TextFont, ""))
+        save()
+    }
+
     override fun applyPreset(index: Int): Boolean {
         val preset = DefaultData.readConfigs.getOrNull(index) ?: return false
         val copy = GSON.fromJsonObject<ReadBookConfig.Config>(GSON.toJson(preset)).getOrNull()
@@ -84,14 +96,15 @@ class ReadBookStyleConfigRepository(
     }
 
     override fun addStyle(): Int {
-        val index = ReadBookConfig.addConfig(ReadBookConfig.Config())
+        val index = configStore.addConfig(ReadBookConfig.Config())
         save()
         return index
     }
 
     override fun deleteCurrentStyle(): Boolean {
         val deletedConfigName = ReadBookConfig.durConfig.name
-        val removedIndex = ReadBookConfig.deleteDur()
+        val removedIndex = ReadBookConfig.styleSelect
+            .takeIf { configStore.deleteConfigAt(it) }
         if (removedIndex != null) {
             val readIndex = AppConfigStore.getInt(PreferKey.readStyleSelect) ?: 0
             val comicIndex = AppConfigStore.getInt(PreferKey.comicStyleSelect) ?: readIndex
@@ -117,7 +130,7 @@ class ReadBookStyleConfigRepository(
     }
 
     override fun importOrReplaceStyle(bytes: ByteArray): String {
-        val name = ReadBookConfig.importOrReplaceConfig(readStyleRepository.import(bytes))
+        val name = configStore.importOrReplaceConfig(readStyleRepository.import(bytes))
         save()
         return name
     }
@@ -133,141 +146,188 @@ class ReadBookStyleConfigRepository(
         readStyleRepository.saveBackgroundImage(inputStream, displayName)
 
     override fun setCurrentBackgroundImage(path: String) {
-        ReadBookConfig.durConfig.setCurBg(2, path)
+        mutateCurrentStyle { it.withCurBg(2, path) }
         save()
     }
 
     override fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) {
-        if (isNight) {
-            ReadBookConfig.durConfig.bgTypeNight = 2
-            ReadBookConfig.durConfig.bgStrNight = path
-        } else {
-            ReadBookConfig.durConfig.bgType = 2
-            ReadBookConfig.durConfig.bgStr = path
+        mutateCurrentStyle {
+            if (isNight) {
+                it.copy(bgTypeNight = 2, bgStrNight = path)
+            } else {
+                it.copy(bgType = 2, bgStr = path)
+            }
         }
         save()
     }
 
-    override fun exportConfigsJson(): String = GSON.toJson(ReadBookConfig.configsSnapshot())
+    override fun exportConfigsJson(): String = GSON.toJson(configStore.configsSnapshot())
 
-    override fun exportShareConfigJson(): String = GSON.toJson(ReadBookConfig.shareConfigSnapshot())
+    override fun exportShareConfigJson(): String = GSON.toJson(configStore.shareConfigSnapshot())
+
+    override fun allBackgroundImagePaths(): List<String> = configStore.allPicBgStr()
+
+    override fun clearUnusedBackgrounds() {
+        configStore.clearBgAndCache()
+    }
 
     private fun publishState() {
         _state.value = buildState()
     }
 
+    /** 改当前**生效**的那一份(共享排版开着时是共享那份)。 */
+    private inline fun mutateEffective(
+        crossinline transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) = configStore.updateEffective(
+        index = ReadBookConfig.styleSelect,
+        useShare = ReadBookConfig.shareLayout,
+        transform = { transform(it) },
+    )
+
+    /**
+     * 改当前**样式**那一份,共享排版开着时也不动共享那份。
+     * 背景、虚线、状态栏图标、样式名这些按样式独立的项走这里——与 R4.7 之前
+     * 写 `ReadBookConfig.durConfig.x` 的那些分支一一对应。
+     */
+    private inline fun mutateCurrentStyle(
+        crossinline transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) = configStore.updateStyleAt(
+        index = ReadBookConfig.styleSelect,
+        transform = { transform(it) },
+    )
+
     private fun updateInt(key: ReadStyleIntKey, value: Int) {
         when (key) {
-            ReadStyleIntKey.TextSize -> ReadBookConfig.textSize = value
-            ReadStyleIntKey.LineSpacing -> ReadBookConfig.lineSpacingExtra = value
-            ReadStyleIntKey.ParagraphSpacing -> ReadBookConfig.paragraphSpacing = value
-            ReadStyleIntKey.TextBold -> ReadBookConfig.textBold = value
-            ReadStyleIntKey.TitleMode -> ReadBookConfig.titleMode = value
-            ReadStyleIntKey.TitleBold -> ReadBookConfig.titleBold = value
-            ReadStyleIntKey.TitleLineSpacingExtra -> ReadBookConfig.titleLineSpacingExtra = value
-            ReadStyleIntKey.TitleLineSpacingSub -> ReadBookConfig.titleLineSpacingSub = value
-            ReadStyleIntKey.TitleSize -> ReadBookConfig.titleSize = value
-            ReadStyleIntKey.TitleTopSpacing -> ReadBookConfig.titleTopSpacing = value
-            ReadStyleIntKey.TitleBottomSpacing -> ReadBookConfig.titleBottomSpacing = value
-            ReadStyleIntKey.TitleSegType -> ReadBookConfig.titleSegType = value
-            ReadStyleIntKey.TitleSegDistance -> ReadBookConfig.titleSegDistance = value
-            ReadStyleIntKey.HeaderMode -> ReadBookConfig.headerMode = value
-            ReadStyleIntKey.FooterMode -> ReadBookConfig.footerMode = value
-            ReadStyleIntKey.TipHeaderLeft -> ReadBookConfig.tipHeaderLeft = value
-            ReadStyleIntKey.TipHeaderMiddle -> ReadBookConfig.tipHeaderMiddle = value
-            ReadStyleIntKey.TipHeaderRight -> ReadBookConfig.tipHeaderRight = value
-            ReadStyleIntKey.TipFooterLeft -> ReadBookConfig.tipFooterLeft = value
-            ReadStyleIntKey.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = value
-            ReadStyleIntKey.TipFooterRight -> ReadBookConfig.tipFooterRight = value
-            ReadStyleIntKey.HeaderFontSize -> ReadBookConfig.headerFontSize = value
-            ReadStyleIntKey.FooterFontSize -> ReadBookConfig.footerFontSize = value
-            ReadStyleIntKey.PageAnim -> ReadBookConfig.pageAnim = value
-            ReadStyleIntKey.UnderlineHeight -> ReadBookConfig.underlineHeight = value
-            ReadStyleIntKey.UnderlinePadding -> ReadBookConfig.underlinePadding = value
-            ReadStyleIntKey.PaddingTop -> ReadBookConfig.paddingTop = value
-            ReadStyleIntKey.PaddingBottom -> ReadBookConfig.paddingBottom = value
-            ReadStyleIntKey.PaddingLeft -> ReadBookConfig.paddingLeft = value
-            ReadStyleIntKey.PaddingRight -> ReadBookConfig.paddingRight = value
-            ReadStyleIntKey.HeaderPaddingTop -> ReadBookConfig.headerPaddingTop = value
-            ReadStyleIntKey.HeaderPaddingBottom -> ReadBookConfig.headerPaddingBottom = value
-            ReadStyleIntKey.HeaderPaddingLeft -> ReadBookConfig.headerPaddingLeft = value
-            ReadStyleIntKey.HeaderPaddingRight -> ReadBookConfig.headerPaddingRight = value
-            ReadStyleIntKey.FooterPaddingTop -> ReadBookConfig.footerPaddingTop = value
-            ReadStyleIntKey.FooterPaddingBottom -> ReadBookConfig.footerPaddingBottom = value
-            ReadStyleIntKey.FooterPaddingLeft -> ReadBookConfig.footerPaddingLeft = value
-            ReadStyleIntKey.FooterPaddingRight -> ReadBookConfig.footerPaddingRight = value
-            ReadStyleIntKey.BgType -> ReadBookConfig.durConfig.bgType = value
-            ReadStyleIntKey.BgTypeNight -> ReadBookConfig.durConfig.bgTypeNight = value
-            ReadStyleIntKey.BgTypeEInk -> ReadBookConfig.durConfig.bgTypeEInk = value
-            ReadStyleIntKey.BgAlpha -> ReadBookConfig.bgAlpha = value
+            ReadStyleIntKey.TextSize -> mutateEffective { it.copy(textSize = value) }
+            ReadStyleIntKey.LineSpacing -> mutateEffective { it.copy(lineSpacingExtra = value) }
+            ReadStyleIntKey.ParagraphSpacing -> mutateEffective { it.copy(paragraphSpacing = value) }
+            ReadStyleIntKey.TextBold -> mutateEffective { it.copy(textBold = value) }
+            ReadStyleIntKey.TitleMode -> mutateEffective { it.copy(titleMode = value) }
+            ReadStyleIntKey.TitleBold -> mutateEffective { it.copy(titleBold = value) }
+            ReadStyleIntKey.TitleLineSpacingExtra ->
+                mutateEffective { it.copy(titleLineSpacingExtra = value) }
+            ReadStyleIntKey.TitleLineSpacingSub ->
+                mutateEffective { it.copy(titleLineSpacingSub = value) }
+            ReadStyleIntKey.TitleSize -> mutateEffective { it.copy(titleSize = value) }
+            ReadStyleIntKey.TitleTopSpacing -> mutateEffective { it.copy(titleTopSpacing = value) }
+            ReadStyleIntKey.TitleBottomSpacing ->
+                mutateEffective { it.copy(titleBottomSpacing = value) }
+            ReadStyleIntKey.TitleSegType -> mutateEffective { it.copy(titleSegType = value) }
+            ReadStyleIntKey.TitleSegDistance -> mutateEffective { it.copy(titleSegDistance = value) }
+            ReadStyleIntKey.HeaderMode -> mutateEffective { it.copy(headerMode = value) }
+            ReadStyleIntKey.FooterMode -> mutateEffective { it.copy(footerMode = value) }
+            ReadStyleIntKey.TipHeaderLeft -> mutateEffective { it.copy(tipHeaderLeft = value) }
+            ReadStyleIntKey.TipHeaderMiddle -> mutateEffective { it.copy(tipHeaderMiddle = value) }
+            ReadStyleIntKey.TipHeaderRight -> mutateEffective { it.copy(tipHeaderRight = value) }
+            ReadStyleIntKey.TipFooterLeft -> mutateEffective { it.copy(tipFooterLeft = value) }
+            ReadStyleIntKey.TipFooterMiddle -> mutateEffective { it.copy(tipFooterMiddle = value) }
+            ReadStyleIntKey.TipFooterRight -> mutateEffective { it.copy(tipFooterRight = value) }
+            ReadStyleIntKey.HeaderFontSize -> mutateEffective { it.copy(headerFontSize = value) }
+            ReadStyleIntKey.FooterFontSize -> mutateEffective { it.copy(footerFontSize = value) }
+            ReadStyleIntKey.PageAnim -> mutateEffective { it.withCurPageAnim(value) }
+            ReadStyleIntKey.UnderlineHeight -> mutateEffective { it.copy(underlineHeight = value) }
+            ReadStyleIntKey.UnderlinePadding -> mutateEffective { it.copy(underlinePadding = value) }
+            ReadStyleIntKey.PaddingTop -> mutateEffective { it.copy(paddingTop = value) }
+            ReadStyleIntKey.PaddingBottom -> mutateEffective { it.copy(paddingBottom = value) }
+            ReadStyleIntKey.PaddingLeft -> mutateEffective { it.copy(paddingLeft = value) }
+            ReadStyleIntKey.PaddingRight -> mutateEffective { it.copy(paddingRight = value) }
+            ReadStyleIntKey.HeaderPaddingTop -> mutateEffective { it.copy(headerPaddingTop = value) }
+            ReadStyleIntKey.HeaderPaddingBottom ->
+                mutateEffective { it.copy(headerPaddingBottom = value) }
+            ReadStyleIntKey.HeaderPaddingLeft ->
+                mutateEffective { it.copy(headerPaddingLeft = value) }
+            ReadStyleIntKey.HeaderPaddingRight ->
+                mutateEffective { it.copy(headerPaddingRight = value) }
+            ReadStyleIntKey.FooterPaddingTop -> mutateEffective { it.copy(footerPaddingTop = value) }
+            ReadStyleIntKey.FooterPaddingBottom ->
+                mutateEffective { it.copy(footerPaddingBottom = value) }
+            ReadStyleIntKey.FooterPaddingLeft ->
+                mutateEffective { it.copy(footerPaddingLeft = value) }
+            ReadStyleIntKey.FooterPaddingRight ->
+                mutateEffective { it.copy(footerPaddingRight = value) }
+            ReadStyleIntKey.BgAlpha -> mutateEffective { it.copy(bgAlpha = value) }
+            ReadStyleIntKey.BgType -> mutateCurrentStyle { it.copy(bgType = value) }
+            ReadStyleIntKey.BgTypeNight -> mutateCurrentStyle { it.copy(bgTypeNight = value) }
+            ReadStyleIntKey.BgTypeEInk -> mutateCurrentStyle { it.copy(bgTypeEInk = value) }
         }
     }
 
     private fun updateFloat(key: ReadStyleFloatKey, value: Float) {
         when (key) {
-            ReadStyleFloatKey.LetterSpacing -> ReadBookConfig.letterSpacing = value
-            ReadStyleFloatKey.TitleSegScaling -> ReadBookConfig.titleSegScaling = value
-            ReadStyleFloatKey.ShadowRadius -> ReadBookConfig.shadowRadius = value
-            ReadStyleFloatKey.ShadowDx -> ReadBookConfig.shadowDx = value
-            ReadStyleFloatKey.ShadowDy -> ReadBookConfig.shadowDy = value
-            ReadStyleFloatKey.DottedBase -> ReadBookConfig.durConfig.dottedBase = value
-            ReadStyleFloatKey.DottedRatio -> ReadBookConfig.durConfig.dottedRatio = value
+            ReadStyleFloatKey.LetterSpacing -> mutateEffective { it.copy(letterSpacing = value) }
+            ReadStyleFloatKey.TitleSegScaling -> mutateEffective { it.copy(titleSegScaling = value) }
+            ReadStyleFloatKey.ShadowRadius -> mutateEffective { it.copy(shadowRadius = value) }
+            ReadStyleFloatKey.ShadowDx -> mutateEffective { it.copy(shadowDx = value) }
+            ReadStyleFloatKey.ShadowDy -> mutateEffective { it.copy(shadowDy = value) }
+            ReadStyleFloatKey.DottedBase -> mutateCurrentStyle { it.copy(dottedBase = value) }
+            ReadStyleFloatKey.DottedRatio -> mutateCurrentStyle { it.copy(dottedRatio = value) }
         }
     }
 
     private fun updateBoolean(key: ReadStyleBooleanKey, value: Boolean) {
         when (key) {
-            ReadStyleBooleanKey.TextItalic -> ReadBookConfig.textItalic = value
-            ReadStyleBooleanKey.TextShadow -> ReadBookConfig.textShadow = value
-            ReadStyleBooleanKey.Underline -> ReadBookConfig.underline = value
-            ReadStyleBooleanKey.DottedLine -> ReadBookConfig.dottedLine = value
-            ReadStyleBooleanKey.UnderlineExtend -> ReadBookConfig.underlineExtend = value
-            ReadStyleBooleanKey.ShowHeaderLine -> ReadBookConfig.showHeaderLine = value
-            ReadStyleBooleanKey.ShowFooterLine -> ReadBookConfig.showFooterLine = value
-            ReadStyleBooleanKey.ApplyHeaderStyle -> ReadBookConfig.applyHeaderStyle = value
-            ReadStyleBooleanKey.StatusIconDark -> ReadBookConfig.durConfig.setCurStatusIconDark(value)
+            ReadStyleBooleanKey.TextItalic -> mutateEffective { it.copy(textItalic = value) }
+            ReadStyleBooleanKey.TextShadow -> mutateEffective { it.copy(textShadow = value) }
+            ReadStyleBooleanKey.Underline -> mutateEffective { it.copy(underline = value) }
+            ReadStyleBooleanKey.DottedLine -> mutateEffective { it.copy(dottedLine = value) }
+            ReadStyleBooleanKey.UnderlineExtend -> mutateEffective { it.copy(underlineExtend = value) }
+            ReadStyleBooleanKey.ShowHeaderLine -> mutateEffective { it.copy(showHeaderLine = value) }
+            ReadStyleBooleanKey.ShowFooterLine -> mutateEffective { it.copy(showFooterLine = value) }
+            ReadStyleBooleanKey.ApplyHeaderStyle ->
+                mutateEffective { it.copy(applyHeaderStyle = value) }
+            ReadStyleBooleanKey.StatusIconDark ->
+                mutateCurrentStyle { it.withCurStatusIconDark(value) }
         }
     }
 
     private fun updateString(key: ReadStyleStringKey, value: String) {
         when (key) {
-            ReadStyleStringKey.TextFont -> ReadBookConfig.textFont = value
-            ReadStyleStringKey.ParagraphIndent -> ReadBookConfig.paragraphIndent = value
-            ReadStyleStringKey.TitleFont -> ReadBookConfig.titleFont = value
-            ReadStyleStringKey.TitleSegFlag -> ReadBookConfig.titleSegFlag = value
-            ReadStyleStringKey.HeaderFont -> ReadBookConfig.headerFont = value
-            ReadStyleStringKey.FooterFont -> ReadBookConfig.footerFont = value
-            ReadStyleStringKey.CustomTipHeaderLeft -> ReadBookConfig.customTipHeaderLeft = value
-            ReadStyleStringKey.CustomTipHeaderMiddle -> ReadBookConfig.customTipHeaderMiddle = value
-            ReadStyleStringKey.CustomTipHeaderRight -> ReadBookConfig.customTipHeaderRight = value
-            ReadStyleStringKey.CustomTipFooterLeft -> ReadBookConfig.customTipFooterLeft = value
-            ReadStyleStringKey.CustomTipFooterMiddle -> ReadBookConfig.customTipFooterMiddle = value
-            ReadStyleStringKey.CustomTipFooterRight -> ReadBookConfig.customTipFooterRight = value
-            ReadStyleStringKey.BgStr -> ReadBookConfig.durConfig.bgStr = value
-            ReadStyleStringKey.BgStrNight -> ReadBookConfig.durConfig.bgStrNight = value
-            ReadStyleStringKey.BgStrEInk -> ReadBookConfig.durConfig.bgStrEInk = value
-            ReadStyleStringKey.StyleName -> ReadBookConfig.durConfig.name = value
+            ReadStyleStringKey.TextFont -> mutateEffective { it.copy(textFont = value) }
+            ReadStyleStringKey.ParagraphIndent -> mutateEffective { it.copy(paragraphIndent = value) }
+            ReadStyleStringKey.TitleFont -> mutateEffective { it.copy(titleFont = value) }
+            ReadStyleStringKey.TitleSegFlag -> mutateEffective { it.copy(titleSegFlag = value) }
+            ReadStyleStringKey.HeaderFont -> mutateEffective { it.copy(headerFont = value) }
+            ReadStyleStringKey.FooterFont -> mutateEffective { it.copy(footerFont = value) }
+            ReadStyleStringKey.CustomTipHeaderLeft ->
+                mutateEffective { it.copy(customTipHeaderLeft = value) }
+            ReadStyleStringKey.CustomTipHeaderMiddle ->
+                mutateEffective { it.copy(customTipHeaderMiddle = value) }
+            ReadStyleStringKey.CustomTipHeaderRight ->
+                mutateEffective { it.copy(customTipHeaderRight = value) }
+            ReadStyleStringKey.CustomTipFooterLeft ->
+                mutateEffective { it.copy(customTipFooterLeft = value) }
+            ReadStyleStringKey.CustomTipFooterMiddle ->
+                mutateEffective { it.copy(customTipFooterMiddle = value) }
+            ReadStyleStringKey.CustomTipFooterRight ->
+                mutateEffective { it.copy(customTipFooterRight = value) }
+            ReadStyleStringKey.BgStr -> mutateCurrentStyle { it.copy(bgStr = value) }
+            ReadStyleStringKey.BgStrNight -> mutateCurrentStyle { it.copy(bgStrNight = value) }
+            ReadStyleStringKey.BgStrEInk -> mutateCurrentStyle { it.copy(bgStrEInk = value) }
+            ReadStyleStringKey.StyleName -> mutateCurrentStyle { it.copy(name = value) }
         }
     }
 
     private fun updateColor(key: ReadStyleColorKey, value: Int) {
         when (key) {
-            ReadStyleColorKey.Text -> ReadBookConfig.durConfig.setCurTextColor(value)
-            ReadStyleColorKey.TextAccent -> ReadBookConfig.durConfig.setCurTextAccentColor(value)
-            ReadStyleColorKey.Title -> ReadBookConfig.titleColor = value
-            ReadStyleColorKey.TitleNight -> ReadBookConfig.titleColorNight = value
-            ReadStyleColorKey.TipHeader -> ReadBookConfig.tipHeaderColor = value
-            ReadStyleColorKey.TipHeaderNight -> ReadBookConfig.tipHeaderColorNight = value
-            ReadStyleColorKey.TipFooter -> ReadBookConfig.tipFooterColor = value
-            ReadStyleColorKey.TipFooterNight -> ReadBookConfig.tipFooterColorNight = value
-            ReadStyleColorKey.TipDivider -> ReadBookConfig.tipDividerColor = value
-            ReadStyleColorKey.Shadow -> ReadBookConfig.durConfig.setCurShadColor(value)
-            ReadStyleColorKey.Underline -> ReadBookConfig.durConfig.setUnderlineColor(value)
+            ReadStyleColorKey.Title -> mutateEffective { it.copy(titleColor = value) }
+            ReadStyleColorKey.TitleNight -> mutateEffective { it.copy(titleColorNight = value) }
+            ReadStyleColorKey.TipHeader -> mutateEffective { it.copy(tipHeaderColor = value) }
+            ReadStyleColorKey.TipHeaderNight ->
+                mutateEffective { it.copy(tipHeaderColorNight = value) }
+            ReadStyleColorKey.TipFooter -> mutateEffective { it.copy(tipFooterColor = value) }
+            ReadStyleColorKey.TipFooterNight ->
+                mutateEffective { it.copy(tipFooterColorNight = value) }
+            ReadStyleColorKey.TipDivider -> mutateEffective { it.copy(tipDividerColor = value) }
+            ReadStyleColorKey.Text -> mutateCurrentStyle { it.withCurTextColor(value) }
+            ReadStyleColorKey.TextAccent -> mutateCurrentStyle { it.withCurTextAccentColor(value) }
+            ReadStyleColorKey.Shadow -> mutateCurrentStyle { it.withCurShadowColor(value) }
+            ReadStyleColorKey.Underline -> mutateCurrentStyle { it.withCurUnderlineColor(value) }
         }
     }
 
     private fun buildState(): ReadStyleState = ReadStyleState(
-        items = ReadBookConfig.configsSnapshot().map { config ->
+        revision = stateRevision.incrementAndGet(),
+        items = configStore.configsSnapshot().map { config ->
             ReadStyleItem(
                 name = config.name,
                 bgType = config.bgType,

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

@@ -558,8 +558,15 @@ class ReadSettingsRepository(
 }
 
 /**
- * ReadSettings 是 101 字段的读取超集;gateway update 当前只承诺持久化以下 48 个键。
- * 扩展此边界时必须同步更新 ReadSettingsMappingTest 的显式键集契约。
+ * [ReadSettings] 每个字段到 DataStore 键的完整映射。
+ *
+ * 必须覆盖全部字段:通用 `update {}` 只持久化本表里的键,漏一个就是静默丢写——
+ * 调用方以为存了,重启后值回退。此前只覆盖 48/102,其余靠专用 setter 兜底,
+ * 但没有任何机制阻止新代码走 `update {}` 改到未覆盖的字段。
+ * [ReadSettingsGatewayMapCoverageTest] 断言这张表与 [ReadSettings] 字段一一对应。
+ *
+ * 全量写不是问题:`AppConfigStore.atomicUpdate` 只把与旧值不同的键入队
+ * (见 `atomicDiffLocked`),未被 transform 改动的字段不会产生写入。
  */
 internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.screenOrientation to screenOrientation,
@@ -568,7 +575,7 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.hideNavigationBar to hideNavigationBar,
     PreferKey.paddingDisplayCutouts to paddingDisplayCutouts,
     PreferKey.titleBarMode to titleBarMode,
-    PreferKey.readMenuBlurAlpha to readMenuBlurAlpha,
+    PreferKey.menuAlpha to menuAlpha,
     PreferKey.readBodyToLh to readBodyToLh,
     PreferKey.defaultSourceChangeAll to defaultSourceChangeAll,
     PreferKey.textFullJustify to textFullJustify,
@@ -598,14 +605,71 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.clickImgWay to clickImgWay,
     PreferKey.optimizeRender to optimizeRender,
     PreferKey.disableReturnKey to disableReturnKey,
-    PreferKey.showReadTitleAddition to showReadTitleAddition,
+    PreferKey.expandTextMenu to expandTextMenu,
+    PreferKey.showSelectMenuIcon to showSelectMenuIcon,
     PreferKey.textSelectMenuConfig to textSelectMenuConfig,
-    PreferKey.readUrlOpenInBrowser to readUrlInBrowser,
-    PreferKey.showMenuIcon to showMenuIcon,
-    PreferKey.titleBarCompact to titleBarCompact,
+    PreferKey.showReadTitleAddition to showReadTitleAddition,
+    PreferKey.autoReadSpeed to autoReadSpeed,
+    PreferKey.systemTypefaces to systemTypefaces,
+    PreferKey.preDownloadNum to preDownloadNum,
     PreferKey.prevKeys to prevKeys,
     PreferKey.nextKeys to nextKeys,
+    PreferKey.tocUiUseReplace to tocUiUseReplace,
+    PreferKey.tocCountWords to tocCountWords,
+    PreferKey.readUrlOpenInBrowser to readUrlInBrowser,
+    PreferKey.readStyleSelect to readStyleSelect,
+    PreferKey.comicStyleSelect to comicStyleSelect,
+    PreferKey.shareLayout to shareLayout,
+    PreferKey.readBarStyleFollowPage to readBarStyleFollowPage,
+    PreferKey.readBarStyle to readBarStyle,
+    PreferKey.clickActionTL to clickActionTL,
+    PreferKey.clickActionTC to clickActionTC,
+    PreferKey.clickActionTR to clickActionTR,
+    PreferKey.clickActionML to clickActionML,
+    PreferKey.clickActionMC to clickActionMC,
+    PreferKey.clickActionMR to clickActionMR,
+    PreferKey.clickActionBL to clickActionBL,
+    PreferKey.clickActionBC to clickActionBC,
+    PreferKey.clickActionBR to clickActionBR,
     PreferKey.fontFolder to fontFolder,
-    PreferKey.systemTypefaces to systemTypefaces,
-    PreferKey.preDownloadNum to preDownloadNum,
+    PreferKey.readMenuBgColor to readMenuBgColor,
+    PreferKey.readMenuAccentColor to readMenuAccentColor,
+    PreferKey.readMenuContainerColor to readMenuContainerColor,
+    PreferKey.readMenuBgColorNight to readMenuBgColorNight,
+    PreferKey.readMenuAccentColorNight to readMenuAccentColorNight,
+    PreferKey.readMenuContainerColorNight to readMenuContainerColorNight,
+    PreferKey.readMenuTextColor to readMenuTextColor,
+    PreferKey.readMenuTextColorNight to readMenuTextColorNight,
+    PreferKey.readMenuColorMode to readMenuColorMode,
+    PreferKey.readMenuIconShowText to readMenuIconShowText,
+    PreferKey.readMenuIconStyle to readMenuIconStyle,
+    PreferKey.titleBarIconStyle to titleBarIconStyle,
+    PreferKey.readMenuIconItemsPerRow to readMenuIconItemsPerRow,
+    PreferKey.readMenuIconRowCount to readMenuIconRowCount,
+    PreferKey.readMenuBottomCornerRadius to readMenuBottomCornerRadius,
+    PreferKey.readMenuFloatingBottomBar to readMenuFloatingBottomBar,
+    PreferKey.readMenuTopBarBlurMode to readMenuTopBarBlurMode,
+    PreferKey.readMenuBottomBarBlurMode to readMenuBottomBarBlurMode,
+    PreferKey.readMenuTopBarLiquidGlassButtons to readMenuTopBarLiquidGlassButtons,
+    PreferKey.readMenuTopBarTitleCapsule to readMenuTopBarTitleCapsule,
+    PreferKey.readMenuBottomBarLiquidGlassButtons to readMenuBottomBarLiquidGlassButtons,
+    PreferKey.readMenuFloatingIconLiquidGlass to readMenuFloatingIconLiquidGlass,
+    PreferKey.readMenuTopBarBlurStyle to readMenuTopBarBlurStyle,
+    PreferKey.readMenuBottomBarBlurStyle to readMenuBottomBarBlurStyle,
+    PreferKey.readMenuBlurRadius to readMenuBlurRadius,
+    PreferKey.readMenuBlurAlpha to readMenuBlurAlpha,
+    PreferKey.readMenuBlurColor to readMenuBlurColor,
+    PreferKey.readMenuBlurColorNight to readMenuBlurColorNight,
+    PreferKey.readMenuPaletteStyle to readMenuPaletteStyle,
+    PreferKey.readMenuLensRadius to readMenuLensRadius,
+    PreferKey.readMenuBorderWidth to readMenuBorderWidth,
+    PreferKey.readMenuBorderColor to readMenuBorderColor,
+    PreferKey.readMenuBorderColorNight to readMenuBorderColorNight,
+    PreferKey.readMenuCustomIcons to readMenuCustomIcons,
+    PreferKey.titleBarCustomIcons to titleBarCustomIcons,
+    PreferKey.titleBarIconPosition to titleBarIconPosition,
+    PreferKey.showTitleBarIcons to showTitleBarIcons,
+    PreferKey.chineseConverterType to chineseConverterType,
+    PreferKey.showMenuIcon to showMenuIcon,
+    PreferKey.titleBarCompact to titleBarCompact,
 )

+ 139 - 0
app/src/main/java/io/legado/app/data/repository/ReadStyleConfigStore.kt

@@ -0,0 +1,139 @@
+package io.legado.app.data.repository
+
+import io.legado.app.help.DefaultData
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.coroutine.Coroutine
+
+/**
+ * 排版配置列表的**唯一所有者**。
+ *
+ * R4.4 把写面收进 gateway 实现之后,`ReadBookConfig` 只剩「选哪一份 + 怎么读」这两件事,
+ * 但列表本体和那把 `@Synchronized` 锁还挂在它身上——状态和保护它的锁分居两处,
+ * 谁都能顺手再往那个全局 object 上塞新状态。本类把两者放回一起:
+ * 上游是文件层 [ReadStyleRepository],下游是 gateway 实现(写)与 `ReadBookConfig`(只读投影)。
+ *
+ * 本类**不知道当前选中哪一份**:`styleSelect` / `shareLayout` 是设置项,
+ * 由 `ReadBookConfig` 解析成下标后再来取用。
+ */
+class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository) {
+
+    private val lock = Any()
+    private val configList = arrayListOf<ReadBookConfig.Config>()
+    private lateinit var shareConfigRef: ReadBookConfig.Config
+
+    val configFilePath: String get() = readStyleRepository.configFilePath
+    val shareConfigFilePath: String get() = readStyleRepository.shareConfigFilePath
+
+    /** 共享排版那一份。 */
+    val shareConfig: ReadBookConfig.Config get() = shareConfigRef
+
+    fun initConfigs() {
+        val configs = readStyleRepository.readConfigs()
+        synchronized(lock) {
+            configList.clear()
+            configList.addAll(configs)
+        }
+    }
+
+    fun initShareConfig() {
+        val fallback = synchronized(lock) { configList.getOrNull(5) } ?: ReadBookConfig.Config()
+        shareConfigRef = readStyleRepository.readShareConfig(fallback)
+    }
+
+    /** 按下标取用。配置文件缺斤少两时先恢复默认,保证 5 份预设始终在。 */
+    fun configAt(index: Int): ReadBookConfig.Config = synchronized(lock) {
+        if (configList.size < 5) {
+            resetAllLocked()
+        }
+        configList.getOrNull(index) ?: configList[0]
+    }
+
+    /** 整份替换(应用预设 / 导入)。共享排版开着时,共享那份跟着换。 */
+    fun replaceConfigAt(index: Int, config: ReadBookConfig.Config, alsoShare: Boolean) {
+        synchronized(lock) {
+            configList[index] = config
+        }
+        if (alsoShare) {
+            shareConfigRef = config
+        }
+    }
+
+    /**
+     * 改**当前样式**那一份的字段。共享排版开着时也只动这一份——背景、虚线、状态栏图标
+     * 这些按样式独立的项走这里。
+     *
+     * 下标解析与 [configAt] 保持一致(含配置文件缺斤少两时先恢复默认、越界回落到 0),
+     * 否则「读得到、写不进」会成为一类静默失效。
+     */
+    fun updateStyleAt(index: Int, transform: (ReadBookConfig.Config) -> ReadBookConfig.Config) {
+        synchronized(lock) {
+            if (configList.size < 5) {
+                resetAllLocked()
+            }
+            val target = if (index in configList.indices) index else 0
+            configList[target] = transform(configList[target])
+        }
+    }
+
+    /** 改**当前生效**那一份:共享排版开着时是共享那份,否则就是当前样式。 */
+    fun updateEffective(
+        index: Int,
+        useShare: Boolean,
+        transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) {
+        if (useShare) {
+            synchronized(lock) { shareConfigRef = transform(shareConfigRef) }
+        } else {
+            updateStyleAt(index, transform)
+        }
+    }
+
+    /** `Config` 的值字段不可变,快照直接共享实例即可。 */
+    fun configsSnapshot(): List<ReadBookConfig.Config> = synchronized(lock) {
+        configList.toList()
+    }
+
+    fun shareConfigSnapshot(): ReadBookConfig.Config = shareConfigRef
+
+    fun addConfig(config: ReadBookConfig.Config): Int = synchronized(lock) {
+        configList.add(config)
+        configList.lastIndex
+    }
+
+    fun importOrReplaceConfig(config: ReadBookConfig.Config): String = synchronized(lock) {
+        val index = configList.indexOfFirst { it.name == config.name }
+        if (index >= 0) {
+            configList[index] = config
+        } else {
+            configList.add(config)
+        }
+        config.name
+    }
+
+    /** 5 份预设不允许删。 */
+    fun deleteConfigAt(index: Int): Boolean = synchronized(lock) {
+        if (configList.size <= 5) return@synchronized false
+        configList.removeAt(index)
+        true
+    }
+
+    /** 备份要把排版引用到的背景图一起带走。 */
+    fun allPicBgStr(): List<String> = readStyleRepository.getAllPicBgStr(configsSnapshot())
+
+    /** 清掉没有任何排版引用的背景图,以及导入导出留下的缓存。 */
+    fun clearBgAndCache() {
+        readStyleRepository.clearBgAndCache(configsSnapshot())
+    }
+
+    private fun resetAllLocked() {
+        configList.clear()
+        configList.addAll(DefaultData.readConfigs)
+        save()
+    }
+
+    private fun save() {
+        Coroutine.async {
+            readStyleRepository.save(configsSnapshot(), shareConfigRef)
+        }
+    }
+}

+ 65 - 81
app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt

@@ -58,14 +58,10 @@ class ReadStyleRepository(
         configs: List<ReadBookConfig.Config>,
         shareConfig: ReadBookConfig.Config
     ) {
-        GSON.toJson(configs).let {
-            FileUtils.delete(configFilePath)
-            FileUtils.createFileIfNotExist(configFilePath).writeText(it)
-        }
-        GSON.toJson(shareConfig).let {
-            FileUtils.delete(shareConfigFilePath)
-            FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it)
-        }
+        // 原子写:两条保存通道(配置 store 的兜底保存与 ReadStyleSaveQueue)会并发落到这里,
+        // 「删掉再写」既有杀进程丢配置的窗口,也会让两者互相看到对方写了一半的文件。
+        FileUtils.writeTextAtomic(configFilePath, GSON.toJson(configs))
+        FileUtils.writeTextAtomic(shareConfigFilePath, GSON.toJson(shareConfig))
     }
 
     fun getAllPicBgStr(configs: List<ReadBookConfig.Config>): ArrayList<String> {
@@ -121,22 +117,24 @@ class ReadStyleRepository(
     fun export(config: ReadBookConfig.Config): ByteArray {
         val exportDir = appCtx.externalCache.getFile("readConfigExport")
         exportDir.createFolderReplace()
-        val exportConfig = config.copy(
+        var exportConfig = config.copy(
             highlightRules = ArrayList(config.highlightRules.map { it.copy() })
         )
         val exportFiles = arrayListOf<File>()
 
-        addBackgroundFile(exportDir, exportConfig, 0, exportFiles)
-        addBackgroundFile(exportDir, exportConfig, 1, exportFiles)
-        addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
-        exportConfig.textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles)
-        exportConfig.titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 0, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 1, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
+        exportConfig = exportConfig.copy(
+            textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles),
+            titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles),
+        )
         HighlightRuleAssetTransfer.prepareExport(
             rules = exportConfig.highlightRules,
             exportDir = exportDir,
             copyAsset = ::copyRuleAsset,
         ).let { result ->
-            exportConfig.highlightRules = ArrayList(result.rules)
+            exportConfig = exportConfig.copy(highlightRules = ArrayList(result.rules))
             exportFiles.addAll(result.files)
         }
 
@@ -159,97 +157,83 @@ class ReadStyleRepository(
         configDir.createFolderReplace()
         ZipUtils.unZipToPath(zipFile, configDir)
         val configFile = configDir.getFile(ReadBookConfig.configFileName)
-        val config: ReadBookConfig.Config =
+        var config: ReadBookConfig.Config =
             GSON.fromJsonObject<ReadBookConfig.Config>(configFile.readText()).getOrThrow()
 
-        config.textFont = importFont(configDir, config.textFont)
-        config.titleFont = importFont(configDir, config.titleFont)
+        config = config.copy(
+            textFont = importFont(configDir, config.textFont),
+            titleFont = importFont(configDir, config.titleFont),
+            bgStr = importBackground(configDir, config.bgType, config.bgStr),
+            bgStrNight = importBackground(configDir, config.bgTypeNight, config.bgStrNight),
+            bgStrEInk = importBackground(configDir, config.bgTypeEInk, config.bgStrEInk),
+        )
 
-        if (config.bgType == 2) {
-            val bgName = FileUtils.getName(config.bgStr)
-            config.bgStr = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStr = bgPath
-        } else if (config.bgType == 0) {
-            config.bgStr.toColorInt()
-        }
-        if (config.bgTypeNight == 2) {
-            val bgName = FileUtils.getName(config.bgStrNight)
-            config.bgStrNight = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStrNight = bgPath
-        } else if (config.bgTypeNight == 0) {
-            config.bgStrNight.toColorInt()
-        }
-        if (config.bgTypeEInk == 2) {
-            val bgName = FileUtils.getName(config.bgStrEInk)
-            config.bgStrEInk = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStrEInk = bgPath
-        } else if (config.bgTypeEInk == 0) {
-            config.bgStrEInk.toColorInt()
-        }
-        config.curTextColor()
-        config.curTextAccentColor()
-        config.curTextShadowColor()
         if (config.highlightRules.isNotEmpty()) {
-            config.highlightRules = ArrayList(
-                HighlightRuleAssetTransfer.restoreImport(
-                    rules = config.highlightRules,
-                    importDir = configDir,
-                    backgroundDir = File(appCtx.filesDir, "bg_images"),
-                    fontDir = appCtx.externalFiles.getFile("font"),
-                    isReadableBackgroundReference = appCtx::isReadableHighlightBackground,
-                    isReadableFontReference = appCtx::isReadableHighlightFont,
-                )
+            val restored = HighlightRuleAssetTransfer.restoreImport(
+                rules = config.highlightRules,
+                importDir = configDir,
+                backgroundDir = File(appCtx.filesDir, "bg_images"),
+                fontDir = appCtx.externalFiles.getFile("font"),
+                isReadableBackgroundReference = appCtx::isReadableHighlightBackground,
+                isReadableFontReference = appCtx::isReadableHighlightFont,
             )
             val targetConfigName = config.name.ifBlank { null }
-            val highlightRules = config.highlightRules.map { rule ->
+            val highlightRules = restored.map { rule ->
                 if (targetConfigName.isNullOrBlank()) {
                     rule.copy(configName = null)
                 } else {
                     rule.copyWithNewId().copy(configName = listOf(targetConfigName).toJsonArray())
                 }
             }
-            config.highlightRules = ArrayList(highlightRules)
+            config = config.copy(highlightRules = ArrayList(highlightRules))
             highlightRuleRepository.saveForConfig(highlightRules, targetConfigName)
         }
+        // 预热 Config 内部的颜色记忆化缓存,让返回的实例首次绘制时不必再解析一遍字符串。
+        // 必须排在最后一次 copy 之后——copy 会把缓存重置回未初始化。
+        config.curTextColor()
+        config.curTextAccentColor()
+        config.curTextShadowColor()
         return config
     }
 
+    /**
+     * 背景是图片(type 2)时把它落到 bg 目录并改写成绝对路径;是颜色(type 0)时
+     * 只做一次解析校验——解析不了就让导入整体失败,而不是导进一份显示不出来的配置。
+     */
+    private fun importBackground(configDir: File, bgType: Int, bgStr: String): String {
+        if (bgType != 2) {
+            if (bgType == 0) {
+                bgStr.toColorInt()
+            }
+            return bgStr
+        }
+        val bgName = FileUtils.getName(bgStr)
+        val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
+        if (!FileUtils.exist(bgPath)) {
+            val bgFile = configDir.getFile(bgName)
+            if (bgFile.exists()) {
+                bgFile.copyTo(File(bgPath))
+            }
+        }
+        return bgPath
+    }
+
     private fun addBackgroundFile(
         exportDir: File,
         config: ReadBookConfig.Config,
         bgIndex: Int,
         exportFiles: MutableList<File>
-    ) {
-        val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return
+    ): ReadBookConfig.Config {
+        val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return config
         val exportedName = addAssetFile(exportDir, sourcePath, exportFiles)
         if (exportedName.isBlank()) {
-            return
+            return config
         }
-        when (bgIndex) {
-            0 -> config.bgStr = exportedName
-            1 -> config.bgStrNight = exportedName
-            2 -> config.bgStrEInk = exportedName
+        return when (bgIndex) {
+            0 -> config.copy(bgStr = exportedName)
+            1 -> config.copy(bgStrNight = exportedName)
+            2 -> config.copy(bgStrEInk = exportedName)
+            else -> config
         }
     }
 

+ 54 - 40
app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt

@@ -1,7 +1,7 @@
 package io.legado.app.data.repository
 
 import android.text.TextUtils
-import io.legado.app.data.appDb
+import io.legado.app.data.dao.ReplaceRuleDao
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.Dispatchers
@@ -9,100 +9,110 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.withContext
 
-class ReplaceRuleRepository {
+class ReplaceRuleRepository(
+    private val dao: ReplaceRuleDao,
+) {
 
     fun flowGroups(): Flow<List<String>> {
-        return appDb.replaceRuleDao.flowGroups().flowOn(Dispatchers.IO)
+        return dao.flowGroups().flowOn(Dispatchers.IO)
     }
 
     fun flowAll(): Flow<List<ReplaceRule>> {
-        return appDb.replaceRuleDao.flowAll().flowOn(Dispatchers.IO)
+        return dao.flowAll().flowOn(Dispatchers.IO)
     }
 
     fun flowNoGroup(): Flow<List<ReplaceRule>> {
-        return appDb.replaceRuleDao.flowNoGroup().flowOn(Dispatchers.IO)
+        return dao.flowNoGroup().flowOn(Dispatchers.IO)
     }
 
     fun flowGroupSearch(key: String): Flow<List<ReplaceRule>> {
-        return appDb.replaceRuleDao.flowGroupSearch(key).flowOn(Dispatchers.IO)
+        return dao.flowGroupSearch(key).flowOn(Dispatchers.IO)
     }
 
     fun flowSearch(key: String): Flow<List<ReplaceRule>> {
-        return appDb.replaceRuleDao.flowSearch(key).flowOn(Dispatchers.IO)
+        return dao.flowSearch(key).flowOn(Dispatchers.IO)
+    }
+
+    suspend fun findById(id: Long): ReplaceRule? = withContext(Dispatchers.IO) {
+        dao.findById(id)
+    }
+
+    suspend fun getNextOrder(): Int = withContext(Dispatchers.IO) {
+        dao.maxOrder + 1
     }
 
     suspend fun update(vararg rule: ReplaceRule) {
         withContext(Dispatchers.IO) {
-            appDb.replaceRuleDao.update(*rule)
+            dao.update(*rule)
         }
     }
 
     suspend fun setEnabled(id: Long, enabled: Boolean) {
         withContext(Dispatchers.IO) {
-            appDb.replaceRuleDao.updateEnabled(id, enabled)
+            dao.updateEnabled(id, enabled)
         }
     }
 
     suspend fun insert(vararg rule: ReplaceRule) {
         withContext(Dispatchers.IO) {
-            appDb.replaceRuleDao.insert(*rule)
+            dao.insert(*rule)
         }
     }
 
     suspend fun delete(rule: ReplaceRule) {
         withContext(Dispatchers.IO) {
-            appDb.replaceRuleDao.delete(rule)
+            dao.delete(rule)
         }
     }
 
     suspend fun toTop(rule: ReplaceRule, isDesc: Boolean = false) {
         withContext(Dispatchers.IO) {
             if (isDesc) {
-                rule.order = appDb.replaceRuleDao.maxOrder + 1
+                rule.order = dao.maxOrder + 1
             } else {
-                rule.order = appDb.replaceRuleDao.minOrder - 1
+                rule.order = dao.minOrder - 1
             }
-            appDb.replaceRuleDao.update(rule)
+            dao.update(rule)
         }
     }
 
     suspend fun toBottom(rule: ReplaceRule, isDesc: Boolean = false) {
         withContext(Dispatchers.IO) {
             if (isDesc) {
-                rule.order = appDb.replaceRuleDao.minOrder - 1
+                rule.order = dao.minOrder - 1
             } else {
-                rule.order = appDb.replaceRuleDao.maxOrder + 1
+                rule.order = dao.maxOrder + 1
             }
-            appDb.replaceRuleDao.update(rule)
+            dao.update(rule)
         }
     }
 
     suspend fun upOrder() {
         withContext(Dispatchers.IO) {
-            val rules = appDb.replaceRuleDao.all
+            val rules = dao.all
             var normalOrder = 1
             rules.forEach { rule ->
                 if (rule.order >= 0) {
                     rule.order = normalOrder++
                 }
             }
-            appDb.replaceRuleDao.update(*rules.toTypedArray())
+            dao.update(*rules.toTypedArray())
         }
     }
 
     suspend fun addGroup(group: String) {
         withContext(Dispatchers.IO) {
-            val sources = appDb.replaceRuleDao.noGroup
+            val sources = dao.noGroup
             sources.forEach { source ->
                 source.group = group
             }
-            appDb.replaceRuleDao.update(*sources.toTypedArray())
+            dao.update(*sources.toTypedArray())
         }
     }
 
     suspend fun upGroup(oldGroup: String, newGroup: String?) {
         withContext(Dispatchers.IO) {
-            val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
+            val sources = dao.getByGroup(oldGroup)
             sources.forEach { source ->
                 source.group?.splitNotBlank(",")?.toHashSet()?.let {
                     it.remove(oldGroup)
@@ -111,61 +121,65 @@ class ReplaceRuleRepository {
                     source.group = TextUtils.join(",", it)
                 }
             }
-            appDb.replaceRuleDao.update(*sources.toTypedArray())
+            dao.update(*sources.toTypedArray())
         }
     }
 
     suspend fun delGroup(group: String) {
         withContext(Dispatchers.IO) {
-            val sources = appDb.replaceRuleDao.getByGroup(group)
+            val sources = dao.getByGroup(group)
             sources.forEach { source ->
                 source.group?.splitNotBlank(",")?.toHashSet()?.let {
                     it.remove(group)
                     source.group = TextUtils.join(",", it)
                 }
             }
-            appDb.replaceRuleDao.update(*sources.toTypedArray())
+            dao.update(*sources.toTypedArray())
         }
     }
 
+    suspend fun clearGroups(groups: List<String>) = withContext(Dispatchers.IO) {
+        dao.clearGroups(groups)
+    }
+
     suspend fun enableByIds(ids: Set<Long>) =
         withContext(Dispatchers.IO) {
             if (ids.isEmpty()) return@withContext
-            appDb.replaceRuleDao.updateEnabled(ids.toList(), true)
+            dao.updateEnabled(ids.toList(), true)
         }
 
     suspend fun disableByIds(ids: Set<Long>) =
         withContext(Dispatchers.IO) {
             if (ids.isEmpty()) return@withContext
-            appDb.replaceRuleDao.updateEnabled(ids.toList(), false)
+            dao.updateEnabled(ids.toList(), false)
         }
 
     suspend fun deleteByIds(ids: Set<Long>) =
         withContext(Dispatchers.IO) {
             if (ids.isEmpty()) return@withContext
 
-            val rules = appDb.replaceRuleDao.getByIds(ids)
-            appDb.replaceRuleDao.delete(*rules.toTypedArray())
+            val rules = dao.getByIds(ids)
+            dao.delete(*rules.toTypedArray())
         }
 
     suspend fun topByIds(ids: Set<Long>, isDesc: Boolean = false) =
         withContext(Dispatchers.IO) {
             if (ids.isEmpty()) return@withContext
-            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val rules = dao.getByIds(ids)
             if (isDesc) {
-                var maxOrder = appDb.replaceRuleDao.maxOrder
+                var maxOrder = dao.maxOrder
                 val updated = rules.map {
                     maxOrder++
                     it.copy(order = maxOrder)
                 }
-                appDb.replaceRuleDao.update(*updated.toTypedArray())
+                dao.update(*updated.toTypedArray())
             } else {
-                var minOrder = appDb.replaceRuleDao.minOrder
+                var minOrder = dao.minOrder
                 val updated = rules.map {
                     minOrder--
                     it.copy(order = minOrder)
                 }
-                appDb.replaceRuleDao.update(*updated.toTypedArray())
+                dao.update(*updated.toTypedArray())
             }
         }
 
@@ -173,21 +187,21 @@ class ReplaceRuleRepository {
         withContext(Dispatchers.IO) {
             if (ids.isEmpty()) return@withContext
 
-            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val rules = dao.getByIds(ids)
             if (isDesc) {
-                var minOrder = appDb.replaceRuleDao.minOrder
+                var minOrder = dao.minOrder
                 val updated = rules.map {
                     minOrder--
                     it.copy(order = minOrder)
                 }
-                appDb.replaceRuleDao.update(*updated.toTypedArray())
+                dao.update(*updated.toTypedArray())
             } else {
-                var maxOrder = appDb.replaceRuleDao.maxOrder
+                var maxOrder = dao.maxOrder
                 val updated = rules.map {
                     maxOrder++
                     it.copy(order = maxOrder)
                 }
-                appDb.replaceRuleDao.update(*updated.toTypedArray())
+                dao.update(*updated.toTypedArray())
             }
         }
 
@@ -198,7 +212,7 @@ class ReplaceRuleRepository {
                 val order = if (isDesc) size - index else index + 1
                 rule.copy(order = order)
             }
-            appDb.replaceRuleDao.update(*updatedRules.toTypedArray())
+            dao.update(*updatedRules.toTypedArray())
         }
     }
 

+ 33 - 0
app/src/main/java/io/legado/app/data/repository/RssArticleRepository.kt

@@ -0,0 +1,33 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RssArticleDao
+import io.legado.app.data.entities.RssArticle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class RssArticleRepository(
+    private val dao: RssArticleDao,
+) {
+
+    suspend fun findByLink(origin: String, link: String): RssArticle? =
+        withContext(Dispatchers.IO) {
+            dao.getByLink(origin, link)
+        }
+
+    suspend fun insert(vararg articles: RssArticle) = withContext(Dispatchers.IO) {
+        dao.insert(*articles)
+    }
+
+    suspend fun append(vararg articles: RssArticle) = withContext(Dispatchers.IO) {
+        dao.append(*articles)
+    }
+
+    suspend fun clearOld(origin: String, sort: String, order: Long) =
+        withContext(Dispatchers.IO) {
+            dao.clearOld(origin, sort, order)
+        }
+
+    suspend fun deleteByOrigin(origin: String) = withContext(Dispatchers.IO) {
+        dao.delete(origin)
+    }
+}

+ 6 - 0
app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt

@@ -2,12 +2,18 @@ package io.legado.app.data.repository
 
 import io.legado.app.data.dao.RssStarDao
 import io.legado.app.data.entities.RssStar
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
 
 class RssFavoriteRepository(private val dao: RssStarDao) {
     fun observeGroups(): Flow<List<String>> = dao.flowGroups()
     fun observeAll(): Flow<List<RssStar>> = dao.liveAll()
     fun observeGroup(group: String): Flow<List<RssStar>> = dao.flowByGroup(group)
+    suspend fun find(origin: String, link: String): RssStar? = withContext(Dispatchers.IO) {
+        dao.get(origin, link)
+    }
+    suspend fun insert(star: RssStar) = withContext(Dispatchers.IO) { dao.insert(star) }
     suspend fun update(star: RssStar) = dao.update(star)
     suspend fun delete(star: RssStar) = dao.delete(star.origin, star.link)
     suspend fun deleteGroup(group: String) = dao.deleteByGroup(group)

+ 23 - 0
app/src/main/java/io/legado/app/data/repository/RssReadRecordRepository.kt

@@ -0,0 +1,23 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RssReadRecordDao
+import io.legado.app.data.entities.RssReadRecord
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class RssReadRecordRepository(
+    private val dao: RssReadRecordDao,
+) {
+
+    suspend fun insert(record: RssReadRecord) = withContext(Dispatchers.IO) {
+        dao.insertRecord(record)
+    }
+
+    fun getAll(): List<RssReadRecord> = dao.getRecords()
+
+    fun count(): Int = dao.countRecords
+
+    suspend fun deleteAll() = withContext(Dispatchers.IO) {
+        dao.deleteAllRecord()
+    }
+}

+ 86 - 5
app/src/main/java/io/legado/app/data/repository/RssRepository.kt

@@ -3,7 +3,9 @@ package io.legado.app.data.repository
 import io.legado.app.data.dao.RssSourceDao
 import io.legado.app.data.entities.RssSource
 import io.legado.app.help.source.SourceHelp
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
 
 class RssRepository(
     private val dao: RssSourceDao
@@ -26,11 +28,28 @@ class RssRepository(
 
     fun getEnabledGroups(): Flow<List<String>> = dao.flowEnabledGroups()
 
-    suspend fun updateSources(vararg sources: RssSource) {
+    fun flowAll(): Flow<List<RssSource>> = dao.flowAll()
+
+    fun flowGroups(): Flow<List<String>> = dao.flowGroups()
+
+    suspend fun getByKey(sourceUrl: String): RssSource? = withContext(Dispatchers.IO) {
+        dao.getByKey(sourceUrl)
+    }
+
+    suspend fun insertSources(vararg sources: RssSource) = withContext(Dispatchers.IO) {
+        dao.insert(*sources)
+    }
+
+    suspend fun updateSources(vararg sources: RssSource) = withContext(Dispatchers.IO) {
         dao.update(*sources)
     }
 
-    suspend fun topSources(vararg sources: RssSource) {
+    suspend fun updateRedirectPolicy(sourceUrl: String, redirectPolicy: String) =
+        withContext(Dispatchers.IO) {
+            dao.updateRedirectPolicy(sourceUrl, redirectPolicy)
+        }
+
+    suspend fun topSources(vararg sources: RssSource) = withContext(Dispatchers.IO) {
         val minOrder = dao.minOrder - 1
         val sortedSources = sources.sortedBy { it.customOrder }
         val updates = Array(sortedSources.size) { index ->
@@ -39,7 +58,7 @@ class RssRepository(
         dao.update(*updates)
     }
 
-    suspend fun bottomSources(vararg sources: RssSource) {
+    suspend fun bottomSources(vararg sources: RssSource) = withContext(Dispatchers.IO) {
         val maxOrder = dao.maxOrder + 1
         val sortedSources = sources.sortedBy { it.customOrder }
         val updates = Array(sortedSources.size) { index ->
@@ -48,12 +67,74 @@ class RssRepository(
         dao.update(*updates)
     }
 
-    suspend fun disableSource(source: RssSource) {
+    suspend fun disableSource(source: RssSource) = withContext(Dispatchers.IO) {
         dao.update(source.copy(enabled = false))
     }
 
     suspend fun deleteSources(sources: List<RssSource>) {
-        SourceHelp.deleteRssSources(sources)
+        withContext(Dispatchers.IO) {
+            SourceHelp.deleteRssSources(sources)
+        }
+    }
+
+    suspend fun deleteByIds(ids: Set<String>) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        SourceHelp.deleteRssSources(dao.getRssSources(*ids.toTypedArray()))
+    }
+
+    suspend fun saveOrder(sources: List<RssSource>) = withContext(Dispatchers.IO) {
+        val updated = sources.mapIndexed { index, source ->
+            source.copy(customOrder = index + 1)
+        }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun normalizeOrder() = withContext(Dispatchers.IO) {
+        val updated = dao.all.mapIndexed { index, source ->
+            source.copy(customOrder = index + 1)
+        }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun setEnabled(ids: Set<String>, enabled: Boolean) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val updated = dao.getRssSources(*ids.toTypedArray()).map { it.copy(enabled = enabled) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun addGroup(ids: Set<String>, group: String) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val updated = dao.getRssSources(*ids.toTypedArray()).map { it.copy().addGroup(group) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun removeGroup(ids: Set<String>, group: String) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val updated = dao.getRssSources(*ids.toTypedArray()).map { it.copy().removeGroup(group) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun renameGroup(oldGroup: String, newGroup: String) = withContext(Dispatchers.IO) {
+        val sources = dao.getByGroup(oldGroup)
+        sources.forEach { source ->
+            source.sourceGroup?.split(",")?.toHashSet()?.let { groups ->
+                groups.remove(oldGroup)
+                if (newGroup.isNotEmpty()) groups.add(newGroup)
+                source.sourceGroup = groups.joinToString(",")
+            }
+        }
+        dao.update(*sources.toTypedArray())
+    }
+
+    suspend fun deleteGroup(group: String) = withContext(Dispatchers.IO) {
+        val sources = dao.getByGroup(group)
+        sources.forEach { source ->
+            source.sourceGroup?.split(",")?.toHashSet()?.let { groups ->
+                groups.remove(group)
+                source.sourceGroup = groups.joinToString(",")
+            }
+        }
+        dao.update(*sources.toTypedArray())
     }
 
     fun getMinOrder(): Int = dao.minOrder

+ 34 - 0
app/src/main/java/io/legado/app/data/repository/RssSourceEditRepository.kt

@@ -0,0 +1,34 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.CacheDao
+import io.legado.app.data.dao.RssArticleDao
+import io.legado.app.data.dao.RssSourceDao
+import io.legado.app.data.dao.RssStarDao
+import io.legado.app.data.entities.RssSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class RssSourceEditRepository(
+    private val sourceDao: RssSourceDao,
+    private val starDao: RssStarDao,
+    private val articleDao: RssArticleDao,
+    private val cacheDao: CacheDao,
+) {
+
+    suspend fun findByUrl(sourceUrl: String): RssSource? = withContext(Dispatchers.IO) {
+        sourceDao.getByKey(sourceUrl)
+    }
+
+    suspend fun save(oldSource: RssSource?, source: RssSource): Boolean =
+        withContext(Dispatchers.IO) {
+            val originChanged = oldSource != null && oldSource.sourceUrl != source.sourceUrl
+            oldSource?.let { sourceDao.delete(it) }
+            if (originChanged) {
+                starDao.updateOrigin(source.sourceUrl, oldSource.sourceUrl)
+                articleDao.updateOrigin(source.sourceUrl, oldSource.sourceUrl)
+                cacheDao.deleteSourceVariables(oldSource.sourceUrl)
+            }
+            sourceDao.insert(source)
+            originChanged
+        }
+}

+ 53 - 0
app/src/main/java/io/legado/app/data/repository/SearchRepository.kt

@@ -1,6 +1,7 @@
 package io.legado.app.data.repository
 
 import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
@@ -25,8 +26,20 @@ interface SearchRepository {
     suspend fun saveSearchKeyword(keyword: String)
     suspend fun deleteSearchKeyword(item: SearchKeyword)
     suspend fun clearSearchKeywords()
+    suspend fun getEnableHasCover(name: String, author: String): List<SearchBook>
+    suspend fun getSearchBook(bookUrl: String): SearchBook?
     suspend fun saveSearchBooks(books: List<SearchBook>)
     suspend fun saveSearchBook(book: SearchBook)
+    suspend fun updateSearchBook(book: SearchBook)
+    suspend fun findChangeSourceBooks(
+        name: String,
+        author: String,
+        screenKey: String = "",
+        group: String = "",
+    ): List<SearchBook>
+    suspend fun deleteSearchBooks(books: List<SearchBook>)
+    suspend fun getBookSourcePart(sourceUrl: String): BookSourcePart?
+    suspend fun getBookSource(sourceUrl: String): BookSource?
 }
 
 class SearchRepositoryImpl(
@@ -105,6 +118,16 @@ class SearchRepositoryImpl(
         appDb.bookSourceDao.getBookSource(sourceUrl)
     }
 
+    override suspend fun getEnableHasCover(name: String, author: String): List<SearchBook> =
+        withContext(Dispatchers.IO) {
+            appDb.searchBookDao.getEnableHasCover(name, author)
+        }
+
+    override suspend fun getSearchBook(bookUrl: String): SearchBook? =
+        withContext(Dispatchers.IO) {
+            appDb.searchBookDao.getSearchBook(bookUrl)
+        }
+
     override suspend fun saveSearchBooks(books: List<SearchBook>): Unit =
         withContext(Dispatchers.IO) {
         if (books.isNotEmpty()) {
@@ -115,4 +138,34 @@ class SearchRepositoryImpl(
     override suspend fun saveSearchBook(book: SearchBook): Unit = withContext(Dispatchers.IO) {
         appDb.searchBookDao.insert(book)
     }
+
+    override suspend fun updateSearchBook(book: SearchBook): Unit = withContext(Dispatchers.IO) {
+        appDb.searchBookDao.update(book)
+    }
+
+    override suspend fun findChangeSourceBooks(
+        name: String,
+        author: String,
+        screenKey: String,
+        group: String,
+    ): List<SearchBook> = withContext(Dispatchers.IO) {
+        if (screenKey.isEmpty()) {
+            appDb.searchBookDao.changeSourceByGroup(name, author, group)
+        } else {
+            appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group)
+        }
+    }
+
+    override suspend fun deleteSearchBooks(books: List<SearchBook>): Unit =
+        withContext(Dispatchers.IO) {
+            if (books.isNotEmpty()) {
+                appDb.searchBookDao.delete(*books.toTypedArray())
+            }
+        }
+
+    override suspend fun getBookSourcePart(sourceUrl: String): BookSourcePart? =
+        withContext(Dispatchers.IO) {
+            appDb.bookSourceDao.getBookSourcePart(sourceUrl)
+        }
+
 }

+ 4 - 0
app/src/main/java/io/legado/app/data/repository/TagGroupRuleRepository.kt

@@ -14,6 +14,10 @@ class TagGroupRuleRepository {
         return dao.flowAll()
     }
 
+    suspend fun getAll(): List<TagGroupRule> = withContext(Dispatchers.IO) {
+        dao.getAll()
+    }
+
     suspend fun insert(vararg rule: TagGroupRule) {
         withContext(Dispatchers.IO) {
             dao.insert(*rule)

+ 9 - 4
app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt

@@ -1,13 +1,14 @@
 package io.legado.app.data.repository
 
-import io.legado.app.data.appDb
+import io.legado.app.data.dao.TxtTocRuleDao
 import io.legado.app.data.entities.TxtTocRule
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
 
-class TxtTocRuleRepository {
-    private val dao = appDb.txtTocRuleDao
+class TxtTocRuleRepository(
+    private val dao: TxtTocRuleDao,
+) {
 
     fun flowAll(): Flow<List<TxtTocRule>> = dao.observeAll()
 
@@ -25,6 +26,10 @@ class TxtTocRuleRepository {
         dao.delete(*rules)
     }
 
+    suspend fun findById(id: Long): TxtTocRule? = withContext(Dispatchers.IO) {
+        dao.get(id)
+    }
+
     suspend fun deleteByIds(ids: Collection<Long>) = withContext(Dispatchers.IO) {
         val rules = dao.getByIds(ids.toSet())
         dao.delete(*rules.toTypedArray())
@@ -48,4 +53,4 @@ class TxtTocRuleRepository {
     fun enabled(): List<TxtTocRule> = dao.enabled
 
     fun count(): Int = dao.count
-}
+}

+ 81 - 2
app/src/main/java/io/legado/app/di/appModule.kt

@@ -19,12 +19,15 @@ import io.legado.app.data.repository.AppStartupRepository
 import io.legado.app.data.repository.AppUiConfigurationRepository
 import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BackupSettingsRepository
+import io.legado.app.data.repository.BookCacheManageRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
+import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.data.repository.BookContentProcessRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookGroupMutationRepository
 import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookImportRepository
 import io.legado.app.data.repository.BookKnowledgeRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
@@ -47,10 +50,12 @@ import io.legado.app.data.repository.DownloadCacheSettingsRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.data.repository.HighlightTagRuleRepository
 import io.legado.app.data.repository.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.HomepageSettingsRepository
 import io.legado.app.data.repository.HttpTtsEngineRepository
+import io.legado.app.data.repository.HttpTtsRepository
 import io.legado.app.data.repository.ImportBookSettingsRepository
 import io.legado.app.data.repository.LabSettingsRepository
 import io.legado.app.data.repository.LocalBookRepository
@@ -63,11 +68,15 @@ import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.data.repository.ReadStyleConfigStore
 import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
+import io.legado.app.data.repository.RssArticleRepository
 import io.legado.app.data.repository.RssFavoriteRepository
+import io.legado.app.data.repository.RssReadRecordRepository
 import io.legado.app.data.repository.RssRepository
+import io.legado.app.data.repository.RssSourceEditRepository
 import io.legado.app.data.repository.RuleSubscriptionRepository
 import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
@@ -179,6 +188,8 @@ import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
 import io.legado.app.domain.usecase.TranslateChapterUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
+import io.legado.app.model.ReaderSession
+import io.legado.app.model.LegacyReaderSession
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
@@ -189,6 +200,12 @@ import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.ai.chat.AiChatViewModel
+import io.legado.app.ui.association.ImportBookSourceViewModel
+import io.legado.app.ui.association.ImportDictRuleViewModel
+import io.legado.app.ui.association.ImportHttpTtsViewModel
+import io.legado.app.ui.association.ImportReplaceRuleViewModel
+import io.legado.app.ui.association.ImportRssSourceViewModel
+import io.legado.app.ui.association.ImportTxtTocRuleViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
 import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -201,7 +218,11 @@ import io.legado.app.ui.book.import.local.ImportBookViewModel
 import io.legado.app.ui.book.import.remote.RemoteBookViewModel
 import io.legado.app.ui.book.import.remote.ServerConfigViewModel
 import io.legado.app.ui.book.import.remote.ServersViewModel
+import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.info.BookInfoViewModel
+import io.legado.app.ui.book.info.edit.BookInfoEditViewModel
+import io.legado.app.ui.login.SourceLoginViewModel
+import io.legado.app.ui.browser.WebViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterDetailViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterListViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
@@ -290,7 +311,10 @@ val appModule = module {
     singleOf(::ReadRecordRepository)
     single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
     singleOf(::BookRepository)
+    singleOf(::BookImportRepository)
     singleOf(::BookGroupRepository)
+    singleOf(::BookmarkRepository)
+    singleOf(::BookCacheManageRepository)
     singleOf(::TagGroupRuleApplier)
     single<BookGroupMutationGateway> { BookGroupMutationRepository(get(), get()) }
     singleOf(::BookSourceRepository)
@@ -335,11 +359,18 @@ val appModule = module {
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
+    // R2.3:会话每个所有者一份。ReadBook.callBack 的身份是「阅读页已挂载」信号
+    // (prefetchForOpen / upData 判 callBack != null),register 还会给上一个持有者
+    // 发 notifyBookChanged——单例会把两个 ReadBookViewModel 的注册身份混成一个。
+    factory<ReaderSession> { LegacyReaderSession() }
     single<HttpTtsEngineGateway> { HttpTtsEngineRepository(get()) }
     single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
+    singleOf(::HttpTtsRepository)
     singleOf(::ApplyReadSettingUseCase)
     singleOf(::HighlightRuleRepository)
+    singleOf(::HighlightTagRuleRepository)
     singleOf(::ReadStyleRepository)
+    singleOf(::ReadStyleConfigStore)
     singleOf(::ReadBookStyleConfigRepository)
     single<ReadStyleGateway> { get<ReadBookStyleConfigRepository>() }
     singleOf(::ExploreBooksUseCase)
@@ -410,6 +441,9 @@ val appModule = module {
     single<ExploreBooksGateway> { get<ExploreRepositoryImpl>() }
     singleOf(::RssRepository)
     singleOf(::RssFavoriteRepository)
+    singleOf(::RssArticleRepository)
+    singleOf(::RssReadRecordRepository)
+    singleOf(::RssSourceEditRepository)
     singleOf(::RuleSubscriptionRepository)
     single {
         SearchRepositoryImpl(get())
@@ -448,6 +482,12 @@ val appModule = module {
     }
 
     viewModelOf(::DictRuleViewModel)
+    viewModelOf(::ImportBookSourceViewModel)
+    viewModelOf(::ImportDictRuleViewModel)
+    viewModelOf(::ImportHttpTtsViewModel)
+    viewModelOf(::ImportReplaceRuleViewModel)
+    viewModelOf(::ImportRssSourceViewModel)
+    viewModelOf(::ImportTxtTocRuleViewModel)
     viewModelOf(::HighlightTagRuleViewModel)
     viewModelOf(::TagGroupRuleViewModel)
     viewModelOf(::DictViewModel)
@@ -475,7 +515,13 @@ val appModule = module {
     viewModelOf(::ReplaceRuleViewModel)
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
-    viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
+    viewModel {
+        TxtTocRulePreviewViewModel(
+            app = get(),
+            bookRepository = get(),
+            repository = get(),
+        )
+    }
     viewModel {
         OtherConfigViewModel(
             appLocaleGateway = get(),
@@ -523,6 +569,34 @@ val appModule = module {
     viewModelOf(::ServerConfigViewModel)
     viewModelOf(::ServersViewModel)
     viewModelOf(::BookInfoViewModel)
+    viewModel {
+        WebViewModel(
+            application = get(),
+            bookSourceRepository = get(),
+        )
+    }
+    viewModel {
+        BookInfoEditViewModel(
+            application = get(),
+            bookRepository = get(),
+        )
+    }
+    viewModel {
+        AudioPlayViewModel(
+            application = get(),
+            bookRepository = get(),
+        )
+    }
+    viewModel {
+        SourceLoginViewModel(
+            application = get(),
+            bookRepository = get(),
+            bookSourceRepository = get(),
+            rssRepository = get(),
+            httpTtsRepository = get(),
+            searchRepository = get(),
+        )
+    }
     viewModel { (bookUrl: String, characterId: String?) ->
         BookCharacterDetailViewModel(
             bookUrl = bookUrl,
@@ -612,6 +686,11 @@ val appModule = module {
             downloadCacheSettingsGateway = get(),
             backupSettingsGateway = get(),
             themeSettingsGateway = get(),
+            httpTtsRepository = get(),
+            bookSourceRepository = get(),
+            bookmarkRepository = get(),
+            bookRepository = get(),
+            readerSession = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)
@@ -643,7 +722,7 @@ val appModule = module {
     viewModel { (route: ReplaceEditRoute) ->
         ReplaceEditViewModel(
             app = get(),
-            replaceRuleDao = get(),
+            replaceRuleRepository = get(),
             route = route
         )
     }

+ 5 - 3
app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt

@@ -8,9 +8,11 @@ interface ReadSettingsGateway {
     val settings: Flow<ReadSettings>
 
     /**
-     * 仅持久化 gateway 实现映射声明的 45 个配置键。
-     * [ReadSettings] 是包含遗留阅读菜单/样式字段的读模型超集;未纳入 gateway 映射的字段
-     * 仍须通过对应的遗留 Repository setter 写入,不能在此处 copy 后期待自动落盘。
+     * 持久化 [ReadSettings] 的任意字段:transform 里 copy 谁,谁就落盘。
+     *
+     * R1.5 之前只覆盖 46/102 个键,copy 到未覆盖的字段会被静默丢弃;现在实现侧的映射
+     * 与 [ReadSettings] 一一对应,由 `ReadSettingsGatewayCoverageTest` 把关。
+     * 只有被 transform 改动的键会产生写入。
      */
     suspend fun update(transform: (ReadSettings) -> ReadSettings)
 }

+ 19 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadStyleGateway.kt

@@ -10,7 +10,20 @@ interface ReadStyleGateway {
 
     fun refresh()
     fun save()
+
+    /**
+     * 日夜 / 墨水屏模式切换后调用。
+     *
+     * 排版值本身没变,但**解析后的生效值**变了(`curTextColor()`/`curUnderlineColor()`/
+     * `curTextShadowColor()` 等都按当前模式取值),订阅方必须重新派生快照。
+     */
+    fun notifyModeChanged()
     fun updateCurrentStyle(mutation: ReadStyleMutation)
+
+    /**
+     * 排版时发现当前字体文件已不存在——清空字体设置并落盘,下次排版回落到系统字体。
+     */
+    fun clearMissingTextFont()
     fun applyPreset(index: Int): Boolean
     fun addStyle(): Int
     fun deleteCurrentStyle(): Boolean
@@ -22,4 +35,10 @@ interface ReadStyleGateway {
     fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean)
     fun exportConfigsJson(): String
     fun exportShareConfigJson(): String
+
+    /** 备份要把所有排版引用到的背景图一起带走。 */
+    fun allBackgroundImagePaths(): List<String>
+
+    /** 清掉没有任何排版引用的背景图,以及导入导出留下的缓存。 */
+    fun clearUnusedBackgrounds()
 }

+ 9 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt

@@ -4,6 +4,15 @@ data class ReadStyleState(
     val items: List<ReadStyleItem> = emptyList(),
     val selectedIndex: Int = 0,
     val shareLayout: Boolean = false,
+    /**
+     * 排版配置的变更序号,每次发布递增。
+     *
+     * 排版底座 `ReadBookConfig.Config` 是可变全局、无 flow,本状态只投影了
+     * items/selectedIndex/shareLayout 三项。没有本字段时,「只改了字号/行距/下划线」这类
+     * 变更不会让 data class 的相等性发生变化,`StateFlow` 会把发射当成重复值丢掉,
+     * 订阅方永远收不到通知。
+     */
+    val revision: Long = 0L,
 )
 
 data class ReadStyleItem(

Разница между файлами не показана из-за своего большого размера
+ 249 - 626
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt


+ 6 - 17
app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt

@@ -41,26 +41,15 @@ object ReadStyleResolver {
         return ReadSessionState.isDarkThemeOverride ?: ReadConfig.isNightTheme
     }
 
-    fun setCurrentBackground(
+    fun withCurrentBackground(
         config: ReadBookConfig.Config,
         bgType: Int,
         bg: String
-    ) {
-        when (currentMode()) {
-            ReadStyleMode.EInk -> {
-                config.bgTypeEInk = bgType
-                config.bgStrEInk = bg
-            }
-
-            ReadStyleMode.Night -> {
-                config.bgTypeNight = bgType
-                config.bgStrNight = bg
-            }
-
-            ReadStyleMode.Day -> {
-                config.bgType = bgType
-                config.bgStr = bg
-            }
+    ): ReadBookConfig.Config {
+        return when (currentMode()) {
+            ReadStyleMode.EInk -> config.copy(bgTypeEInk = bgType, bgStrEInk = bg)
+            ReadStyleMode.Night -> config.copy(bgTypeNight = bgType, bgStrNight = bg)
+            ReadStyleMode.Day -> config.copy(bgType = bgType, bgStr = bg)
         }
     }
 

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

@@ -5,9 +5,10 @@ import android.graphics.Bitmap
 import android.util.DisplayMetrics
 import androidx.annotation.Keep
 import androidx.core.graphics.toColorInt
+import io.legado.app.ui.book.read.ConfigUpdateAction
+import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.R
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Theme
 import io.legado.app.help.DefaultData
@@ -22,7 +23,6 @@ import io.legado.app.utils.getFile
 import io.legado.app.utils.getPrefInt
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hexString
-import io.legado.app.utils.postEvent
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.stackBlur
 import splitties.init.appCtx
@@ -45,7 +45,7 @@ object ThemeConfigStore {
 
     fun applyDayNight(context: Context) {
         initNightMode()
-        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+        ReadConfigUpdateBus.post(setOf(ConfigUpdateAction.UpdateStyle))
     }
 
     /**
@@ -54,7 +54,7 @@ object ThemeConfigStore {
      */
     fun applyDayNightLive() {
         initNightMode()
-        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+        ReadConfigUpdateBus.post(setOf(ConfigUpdateAction.UpdateStyle))
     }
 
     fun applyDayNightInit(context: Context) {
@@ -96,8 +96,7 @@ object ThemeConfigStore {
 
     fun save() {
         val json = GSON.toJson(configList)
-        FileUtils.delete(configFilePath)
-        FileUtils.createFileIfNotExist(configFilePath).writeText(json)
+        FileUtils.writeTextAtomic(configFilePath, json)
     }
 
     fun addConfig(json: String): Boolean {

+ 1 - 1
app/src/main/java/io/legado/app/help/storage/Backup.kt

@@ -308,7 +308,7 @@ object Backup {
         FileUtils.delete(backupPath)
         FileUtils.delete(zipFilePath)
         currentCoroutineContext().ensureActive()
-        ReadBookConfig.getAllPicBgStr().map {
+        readStyleGateway.allBackgroundImagePaths().map {
             if (it.contains(File.separator)) {
                 File(it)
             } else {

+ 7 - 8
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -34,6 +34,7 @@ import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
@@ -321,8 +322,7 @@ object Restore : KoinComponent {
             File(path, ThemeConfigStore.configFileName).takeIf {
                 it.exists()
             }?.runCatching {
-                FileUtils.delete(ThemeConfigStore.configFilePath)
-                copyTo(File(ThemeConfigStore.configFilePath))
+                FileUtils.copyFileAtomic(this, ThemeConfigStore.configFilePath)
                 ThemeConfigStore.upConfig()
             }?.onFailure {
                 AppLog.put("恢复主题出错\n${it.localizedMessage}", it)
@@ -341,21 +341,20 @@ object Restore : KoinComponent {
             File(path, ReadBookConfig.configFileName).takeIf {
                 it.exists()
             }?.runCatching {
-                FileUtils.delete(ReadBookConfig.configFilePath)
-                copyTo(File(ReadBookConfig.configFilePath))
-                ReadBookConfig.initConfigs()
+                FileUtils.copyFileAtomic(this, ReadBookConfig.configFilePath)
             }?.onFailure {
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
             }
             File(path, ReadBookConfig.shareConfigFileName).takeIf {
                 it.exists()
             }?.runCatching {
-                FileUtils.delete(ReadBookConfig.shareConfigFilePath)
-                copyTo(File(ReadBookConfig.shareConfigFilePath))
-                ReadBookConfig.initShareConfig()
+                FileUtils.copyFileAtomic(this, ReadBookConfig.shareConfigFilePath)
             }?.onFailure {
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
             }
+            // 两个文件都落地后再整体重读:分开重读会让 shareConfig 的兜底
+            // (configList[5])取到还没被覆盖的旧列表。refresh 顺带发布 state。
+            get<ReadStyleGateway>().refresh()
         }
         // 恢复配置文件 (手动解析 XML,替代反射逻辑)
         val configFile = File(path, "config.xml")

+ 4 - 3
app/src/main/java/io/legado/app/model/FullBookPaginator.kt

@@ -1,5 +1,6 @@
 package io.legado.app.model
 
+import io.legado.app.constant.ReadTipType
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.help.config.ReadBookConfig
@@ -68,9 +69,9 @@ object FullBookPaginator {
                 ReadBookConfig.customTipFooterRight,
             ),
         ),
-        customTipValue = ReadBookConfig.tipCustom,
-        wholeBookPageTipValue = ReadBookConfig.tipWholeBookPage,
-        wholeBookPageAndProgressTipValue = ReadBookConfig.tipWholeBookPageAndProgress,
+        customTipValue = ReadTipType.tipCustom,
+        wholeBookPageTipValue = ReadTipType.tipWholeBookPage,
+        wholeBookPageAndProgressTipValue = ReadTipType.tipWholeBookPageAndProgress,
     )
 }
 

+ 187 - 35
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -35,6 +35,8 @@ import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
+import io.legado.app.ui.book.read.ConfigUpdateAction
+import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
@@ -65,6 +67,8 @@ import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.isActive
@@ -82,15 +86,51 @@ import kotlin.math.max
 import kotlin.math.min
 
 
+/**
+ * ReadBook 会话的权威只读快照。
+ *
+ * 只承载 ReadBook **自己拥有、并在受控 mutator 中重新发布**的会话字段,全部为廉价标量,
+ * 不含可变 Book / TextChapter / List / Map,滚动高频路径也不会深拷贝章节。
+ *
+ * 刻意不含 `isReadingAloud` / `isLoading`:前者真实来源是 `BaseReadAloudService.isRun`
+ * (独立服务,ReadBook 变更不会触发其重发),后者只有 `msg`/`loadingChapters` 间接信号;
+ * 塞进来会得到静默陈旧字段。需要时应让其来源方参与发布,另行引入。
+ */
+data class LegacyReaderSnapshot(
+    val bookUrl: String? = null,
+    val bookName: String? = null,
+    val chapterIndex: Int = 0,
+    val chapterPos: Int = 0,
+    val chapterCount: Int = 0,
+    val simulatedChapterCount: Int = 0,
+    val isLocalBook: Boolean = true,
+)
+
 @Suppress("MemberVisibilityCanBePrivate")
 object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var book: Book? = null
+        private set
     var callBack: CallBack? = null
+
+    /**
+     * 渲染回调槽位(Track B2):由 UI 层渲染控制器实现,承接指令式渲染协议。
+     * 与状态槽位 [callBack] 分离,使指令式渲染不再穿过 ViewModel。
+     */
+    var renderCallBack: ReaderRenderCallback? = null
     var inBookshelf = false
     var chapterSize = 0
     var simulatedChapterSize = 0
+    // 主线程写(翻页/换章/进度跳转),IO 线程读——layoutLoadedChapter 在
+    // Dispatchers.IO 上用它们判断当前页是否已排版(见 when(chapter.index - durChapterIndex)
+    // 与 page.containPos(durChapterPos))。没有 @Volatile 时 IO 侧可能读到陈旧值,
+    // 表现为「排版完了但没刷新到当前页」。
+    @Volatile
     var durChapterIndex = 0
+        private set
+
+    @Volatile
     var durChapterPos = 0
+        private set
     var isLocalBook = true
     var chapterChanged = false
     @Volatile
@@ -156,6 +196,58 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     private const val MIN_READ_DURATION = 10 * 1000L
 
+    // region 会话所有权:外部只能通过下列语义化命令改写会话字段(book/durChapterIndex/durChapterPos
+    // 已 private set)。判定用意图化谓词替代裸实体比较,避免调用方直接持有可变 Book。
+
+    private val _snapshot = MutableStateFlow(LegacyReaderSnapshot())
+
+    /** 权威会话快照。每个受控 mutator 在一次原子状态转移完成后 [publishSnapshot]。 */
+    val snapshot: StateFlow<LegacyReaderSnapshot> = _snapshot.asStateFlow()
+
+    private fun buildSnapshot() = LegacyReaderSnapshot(
+        bookUrl = book?.bookUrl,
+        bookName = book?.name,
+        chapterIndex = durChapterIndex,
+        chapterPos = durChapterPos,
+        chapterCount = chapterSize,
+        simulatedChapterCount = simulatedChapterSize,
+        isLocalBook = isLocalBook,
+    )
+
+    /**
+     * 在一次原子状态转移**完成后**调用(而非每次单字段赋值后),只发一次、不暴露中间态。
+     * 过发无害(StateFlow 对相等值去重),漏发才会导致快照陈旧。
+     */
+    private fun publishSnapshot() {
+        _snapshot.value = buildSnapshot()
+    }
+
+    /** 当前会话是否正指向该 URL 的书。 */
+    fun isCurrentBook(bookUrl: String): Boolean = book?.bookUrl == bookUrl
+
+    /** 当前会话是否正指向与 [other] 同名同作者的书。 */
+    fun isCurrentBook(other: Book): Boolean = book?.isSameNameAuthor(other) == true
+
+    /** 用同一本书的新实例替换当前会话持有的 Book(仅替换引用,不重置章节/进度)。 */
+    fun replaceCurrentBook(book: Book) {
+        this.book = book
+        publishSnapshot()
+    }
+
+    /** 清空当前会话持有的 Book(仅置空引用,不动章节/进度状态)。 */
+    fun clearCurrentBook() {
+        book = null
+        publishSnapshot()
+    }
+
+    /** 更新当前章节内的阅读位置。 */
+    fun updateReadingPosition(position: Int) {
+        durChapterPos = position
+        publishSnapshot()
+    }
+
+    // endregion
+
     fun resetData(book: Book) {
         wholeBookPageCoordinator.clear()
         ReadBook.book = book
@@ -173,9 +265,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         durChapterPos = book.durChapterPos
         isLocalBook = book.isLocal
         clearTextChapter()
-        callBack?.upContent()
+        renderCallBack?.upContent()
         callBack?.upMenuView()
-        callBack?.upPageAnim()
+        renderCallBack?.upPageAnim()
         upWebBook(book)
         lastBookProgress = null
         webBookProgress = null
@@ -186,6 +278,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             downloadedChapters.clear()
             downloadFailChapters.clear()
         }
+        publishSnapshot()
     }
 
     fun upData(book: Book) {
@@ -219,6 +312,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             downloadFailChapters.clear()
         }
         requestWholeBookPageEstimate()
+        publishSnapshot()
     }
 
     fun requestWholeBookPageEstimate() {
@@ -362,7 +456,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 layoutGeneration = layoutGeneration,
             )
             val textChapter = ChapterProvider.getTextChapterAsync(
-                this, book, chapter, displayTitle, processed, chapters.size,
+                this, book, bookSource, chapter, displayTitle, processed, chapters.size,
             )
             try {
                 var pageCount = 0
@@ -472,7 +566,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val oldIndex = ReadBookConfig.styleSelect
         ReadSessionState.isComic = book.isImage
         if (oldIndex != ReadBookConfig.styleSelect) {
-            postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
+            ReadConfigUpdateBus.post(
+                setOf(
+                    ConfigUpdateAction.UpdateBackground,
+                    ConfigUpdateAction.UpdateStyle,
+                    ConfigUpdateAction.ReloadContent,
+                    ConfigUpdateAction.RebuildWholeBookPageIndex,
+                )
+            )
             if (ReadConfig.readBarStyleFollowPage) {
                 postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
             }
@@ -486,9 +587,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         ) {
             durChapterIndex = progress.durChapterIndex
             durChapterPos = progress.durChapterPos
+            publishSnapshot()
             saveRead()
             clearTextChapter()
-            callBack?.upContent()
+            renderCallBack?.upContent()
             loadContent(resetPageOffset = true)
         }
     }
@@ -711,7 +813,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun upMsg(msg: String?) {
         if (ReadBook.msg != msg) {
             ReadBook.msg = msg
-            callBack?.upContent()
+            renderCallBack?.upContent()
+            // msg 参与 syncFromReadBook;渲染已下沉后靠快照驱动 VM 状态刷新
+            publishSnapshot()
         }
     }
 
@@ -723,9 +827,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 hasNextPage = true
                 it.getPage(durPageIndex)?.removePageAloudSpan()
                 durChapterPos = nextPagePos
-                callBack?.cancelSelect()
-                callBack?.upContent()
+                renderCallBack?.cancelSelect()
+                renderCallBack?.upContent()
                 saveRead(true)
+                publishSnapshot()
             }
         }
         return hasNextPage
@@ -738,8 +843,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             if (prevPagePos >= 0) {
                 hasPrevPage = true
                 durChapterPos = prevPagePos
-                callBack?.upContent()
+                renderCallBack?.upContent()
                 saveRead(true)
+                publishSnapshot()
             }
         }
         return hasPrevPage
@@ -755,17 +861,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             nextTextChapter = null
             if (curTextChapter == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
-                if (upContentInPlace) callBack?.upContent()
+                if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
                 AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
-                callBack?.upContent()
+                renderCallBack?.upContent()
             }
             loadContent(durChapterIndex.plus(1), upContent, false)
             saveRead()
             callBack?.upMenuView()
             AppLog.putDebug("moveToNextChapter-curPageChanged()")
             curPageChanged()
+            publishSnapshot()
             return true
         } else {
             AppLog.putDebug("跳转下一章失败,没有下一章")
@@ -786,17 +893,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             nextTextChapter = null
             if (curTextChapter == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
-                if (upContentInPlace) callBack?.upContentAwait()
+                if (upContentInPlace) renderCallBack?.upContentAwait()
                 loadContentAwait(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
                 AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
-                callBack?.upContentAwait()
+                renderCallBack?.upContentAwait()
             }
             loadContent(durChapterIndex.plus(1), upContent, false)
             saveRead()
             callBack?.upMenuView()
             AppLog.putDebug("moveToNextChapter-curPageChanged()")
             curPageChanged()
+            publishSnapshot()
             return true
         } else {
             AppLog.putDebug("跳转下一章失败,没有下一章")
@@ -817,15 +925,16 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             curTextChapter = prevTextChapter
             prevTextChapter = null
             if (curTextChapter == null) {
-                if (upContentInPlace) callBack?.upContent()
+                if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
-                callBack?.upContent()
+                renderCallBack?.upContent()
             }
             loadContent(durChapterIndex.minus(1), upContent, false)
             saveRead()
             callBack?.upMenuView()
             curPageChanged()
+            publishSnapshot()
             return true
         } else {
             return false
@@ -834,11 +943,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun skipToPage(index: Int, success: (() -> Unit)? = null) {
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
-        callBack?.upContent {
+        renderCallBack?.upContent {
             success?.invoke()
         }
         curPageChanged()
         saveRead(true)
+        publishSnapshot()
     }
 
     fun setPageIndex(index: Int) {
@@ -846,6 +956,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
         saveRead(true)
         curPageChanged(true)
+        publishSnapshot()
     }
 
     fun recycleRecorders(beforeIndex: Int, afterIndex: Int) {
@@ -871,9 +982,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     ) {
         if (index < chapterSize) {
             clearTextChapter()
-            if (upContent) callBack?.upContent()
+            if (upContent) renderCallBack?.upContent()
             durChapterIndex = index
             ReadBook.durChapterPos = durChapterPos
+            publishSnapshot()
             saveRead()
             loadContent(resetPageOffset = true) {
                 success?.invoke()
@@ -888,7 +1000,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         pageChanged: Boolean = false,
         preserveReadAloudPosition: Boolean = false,
     ) {
-        callBack?.pageChanged()
+        renderCallBack?.pageChanged()
         curTextChapter?.let {
             if (BaseReadAloudService.isRun && it.isCompleted) {
                 if (shouldRestartReadAloudAfterContentLoad(
@@ -925,8 +1037,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun syncReadAloudPage(chapterIndex: Int, chapterPos: Int) {
         if (durChapterIndex != chapterIndex || durChapterPos == chapterPos) return
         durChapterPos = chapterPos
-        callBack?.upContent(resetPageOffset = false)
+        renderCallBack?.upContent(resetPageOffset = false)
         saveRead(pageChanged = true)
+        publishSnapshot()
     }
 
     /**
@@ -1035,7 +1148,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 success?.invoke()
             }
         } else {
-            callBack?.upContent()
+            renderCallBack?.upContent()
+            publishSnapshot()
         }
         val nextChapter = nextTextChapter
         if (nextChapter == null || !nextChapter.isLayoutSizeMatch()) {
@@ -1331,7 +1445,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             layoutGeneration = pageEstimateGeneration,
         )
         val textChapter = ChapterProvider.getTextChapterAsync(
-            this, book, chapter, displayTitle, contents, simulatedChapterSize
+            this, book, bookSource, chapter, displayTitle, contents, simulatedChapterSize
         ).apply {
             this.pageEstimateGeneration = pageEstimateGeneration
         }
@@ -1347,23 +1461,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     val index = page.index
                     if (!available && page.containPos(durChapterPos)) {
                         if (upContent) {
-                            callBack?.upContent(offset, resetPageOffset)
+                            renderCallBack?.upContent(offset, resetPageOffset)
                         }
                         available = true
                     }
                     if (upContent && isScroll) {
                         if (max(index - 3, 0) < durPageIndex) {
-                            callBack?.upContent(offset, false)
+                            renderCallBack?.upContent(offset, false)
                         }
                     }
-                    callBack?.onLayoutPageCompleted(index, page)
+                    renderCallBack?.onLayoutPageCompleted(index, page)
                 }
                 if (layoutCompleted) correctWholeBookPageCount(textChapter)
-                if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
+                if (upContent) renderCallBack?.upContent(offset, !available && resetPageOffset)
                 curPageChanged(
                     preserveReadAloudPosition = preserveReadAloudPosition,
                 )
-                callBack?.contentLoadFinish()
+                renderCallBack?.contentLoadFinish()
+                // 布局全部完成后发一次:seekMax 依赖 curTextChapter.pages.size,
+                // 渲染下沉后靠快照驱动 VM 的进度/章节状态刷新
+                publishSnapshot()
             }
 
             -1 -> prevChapterLoadingLock.withLock {
@@ -1374,7 +1491,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 if (collectLayoutPages(textChapter) {}) {
                     correctWholeBookPageCount(textChapter)
                 }
-                if (upContent) callBack?.upContent(offset, resetPageOffset)
+                if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
             }
 
             1 -> nextChapterLoadingLock.withLock {
@@ -1384,7 +1501,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 }
                 val layoutCompleted = collectLayoutPages(textChapter) { page ->
                     if (page.index > 1) return@collectLayoutPages
-                    if (upContent) callBack?.upContent(offset, resetPageOffset)
+                    if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
                 }
                 if (layoutCompleted) correctWholeBookPageCount(textChapter)
             }
@@ -1473,6 +1590,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 saveRead()
                 chapterSize = cList.size
                 simulatedChapterSize = book.simulatedTotalChapterNum()
+                publishSnapshot()
                 nextTextChapter ?: loadContent(durChapterIndex + 1)
             }
         }
@@ -1569,6 +1687,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 durChapterIndex = simulatedChapterSize - 1
             }
             requestWholeBookPageEstimate()
+            publishSnapshot()
             if (callBack == null) {
                 clearTextChapter()
             } else {
@@ -1608,6 +1727,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
 
+    /**
+     * 注册渲染回调(UI 层渲染控制器)。视图就绪后立即同步一次当前内容,
+     * 避免在注册前已完成的内容加载导致首帧漏渲染。
+     */
+    fun registerRender(cb: ReaderRenderCallback) {
+        renderCallBack = cb
+        if (curTextChapter != null) {
+            cb.upContent()
+        }
+    }
+
+    /**
+     * 取消注册渲染回调
+     */
+    fun unregisterRender(cb: ReaderRenderCallback) {
+        if (renderCallBack === cb) {
+            renderCallBack = null
+        }
+    }
+
     /**
      * 取消注册回调
      */
@@ -1629,11 +1768,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
-    interface CallBack : LayoutProgressListener {
-        fun upMenuView()
-
-        fun loadChapterList(book: Book)
-
+    /**
+     * 渲染子集回调(Track B):指令式渲染协议——重绘、分页、动画、选择取消、
+     * 排版进度。**只应由 UI 层的渲染控制器实现**,不得穿过 ViewModel/业务层。
+     */
+    interface ReaderRenderCallback : LayoutProgressListener {
         fun upContent(
             relativePosition: Int = 0,
             resetPageOffset: Boolean = true,
@@ -1652,11 +1791,24 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
         fun upPageAnim(upRecorder: Boolean = false)
 
+        fun cancelSelect()
+    }
+
+    /**
+     * 业务/UI 状态回调子集:菜单刷新、目录加载、换书通知、进度确认。
+     *
+     * Track B2 起本接口已与渲染子集解耦——不再穿过任何指令式渲染方法。
+     * 渲染走独立的 [renderCallBack](由 UI 层渲染控制器实现),业务状态刷新
+     * 走 [snapshot] → ViewModel 的反应式收集。由 ReadBookViewModel 实现。
+     */
+    interface CallBack {
+        fun upMenuView()
+
+        fun loadChapterList(book: Book)
+
         fun notifyBookChanged()
 
         fun sureNewProgress(progress: BookProgress)
-
-        fun cancelSelect()
     }
 
 }

+ 138 - 0
app/src/main/java/io/legado/app/model/ReaderSession.kt

@@ -0,0 +1,138 @@
+package io.legado.app.model
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookProgress
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * 会话侧一次性事件(R2.3)。
+ *
+ * 遗留的 [ReadBook.CallBack] 四个回调原来由 `ReadBookViewModel` 直接实现,
+ * 于是 `ReadBook.callBack` 这个全局槽位指向 ViewModel。现在改由 [LegacyReaderSession]
+ * 接住并转成事件流,ViewModel 只是订阅者。
+ */
+sealed interface ReaderSessionEvent {
+
+    /** 会话派生状态(菜单/进度等)需要重新同步。对应 `upMenuView`。 */
+    data object StateInvalidated : ReaderSessionEvent
+
+    /** [ReadBook] 请求所有者去加载目录。对应 `loadChapterList`。 */
+    data class ChapterListRequested(val book: Book) : ReaderSessionEvent
+
+    /** 当前会话的书被换掉。对应 `notifyBookChanged`。 */
+    data object BookChanged : ReaderSessionEvent
+
+    /** 云端进度比本地新,需所有者确认。对应 `sureNewProgress`。 */
+    data class NewProgressAvailable(val progress: BookProgress) : ReaderSessionEvent
+}
+
+/**
+ * 阅读会话面向所有者的 API。
+ *
+ * 首个实现 [LegacyReaderSession] 委托给全局单例 [ReadBook]:
+ * - [state] 直接投影 `ReadBook.snapshot`,**不维护竞争副本**;
+ * - 所有 mutation 都经由 ReadBook 的受控 mutator(`private set` + 语义化命令);
+ * - 调用方只拿到只读快照 [LegacyReaderSnapshot],拿不到可变领域对象(Book/TextChapter)。
+ *
+ * 这是迁移期的桥接层:待 Track A 后续把所有权彻底从 ReadBook 收回后,可替换为真正的会话实现。
+ */
+interface ReaderSession {
+
+    /** 权威会话快照流。 */
+    val state: StateFlow<LegacyReaderSnapshot>
+
+    /**
+     * 会话事件流。
+     *
+     * **订阅必须早于 [attach]**:事件用 `tryEmit` 投递,没有订阅者时会被丢弃。
+     * 会话是每个所有者一份(Koin `factory`),订阅者恒为 1。
+     */
+    val events: SharedFlow<ReaderSessionEvent>
+
+    /** 把本会话接到 [ReadBook] 上,成为当前的状态回调持有者。 */
+    fun attach()
+
+    /** 从 [ReadBook] 摘下本会话。 */
+    fun detach()
+
+    /** 当前会话是否正指向该 URL 的书。 */
+    fun isCurrentBook(bookUrl: String): Boolean
+
+    /** 跳转到指定章节与章内位置。 */
+    fun moveToChapter(index: Int, position: Int = 0)
+
+    /** 下一章。 */
+    fun nextChapter()
+
+    /** 上一章。 */
+    fun previousChapter()
+
+    /** 更新当前章节内的阅读位置。 */
+    fun updateViewport(position: Int)
+}
+
+/**
+ * 遗留桥接实现:把 [ReaderSession] 全部委托给全局单例 [ReadBook]。
+ * 命令一一映射到 ReadBook 既有的受控 mutator,不引入并行状态。
+ *
+ * 同时实现 [ReadBook.CallBack]——`ReadBook.callBack` 的身份既是「阅读页已挂载」信号
+ * (`prefetchForOpen` / `upData` 靠 `callBack != null` 判断),也在 `register` 时给
+ * **上一个**持有者发 `notifyBookChanged`。因此本类必须**每个所有者一份**,
+ * attach/detach 与 register/unregister 一一对应,身份语义与迁移前完全一致。
+ */
+class LegacyReaderSession : ReaderSession, ReadBook.CallBack {
+
+    private val _events = MutableSharedFlow<ReaderSessionEvent>(extraBufferCapacity = 16)
+
+    override val events: SharedFlow<ReaderSessionEvent> = _events.asSharedFlow()
+
+    override val state: StateFlow<LegacyReaderSnapshot>
+        get() = ReadBook.snapshot
+
+    override fun attach() {
+        ReadBook.register(this)
+    }
+
+    override fun detach() {
+        ReadBook.unregister(this)
+    }
+
+    override fun isCurrentBook(bookUrl: String): Boolean = ReadBook.isCurrentBook(bookUrl)
+
+    override fun moveToChapter(index: Int, position: Int) {
+        ReadBook.openChapter(index, position)
+    }
+
+    override fun nextChapter() {
+        ReadBook.moveToNextChapter(upContent = true)
+    }
+
+    override fun previousChapter() {
+        ReadBook.moveToPrevChapter(upContent = true)
+    }
+
+    override fun updateViewport(position: Int) {
+        ReadBook.updateReadingPosition(position)
+    }
+
+    // --- ReadBook.CallBack → 事件流 ---
+
+    override fun upMenuView() {
+        _events.tryEmit(ReaderSessionEvent.StateInvalidated)
+    }
+
+    override fun loadChapterList(book: Book) {
+        _events.tryEmit(ReaderSessionEvent.ChapterListRequested(book))
+    }
+
+    override fun notifyBookChanged() {
+        _events.tryEmit(ReaderSessionEvent.BookChanged)
+    }
+
+    override fun sureNewProgress(progress: BookProgress) {
+        _events.tryEmit(ReaderSessionEvent.NewProgressAvailable(progress))
+    }
+}

+ 3 - 0
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -375,6 +375,9 @@ class HttpReadAloudService : BaseReadAloudService(),
         val textChapter = ChapterProvider.getTextChapterAsync(
             CoroutineScope(currentCoroutineContext()),
             book,
+            // 与本函数其余处一致地跟随 ReadBook 当前会话;这里传的就是改造前排版协程
+            // 自己去读的那个值,行为不变
+            ReadBook.bookSource,
             chapter,
             displayTitle,
             processedContent,

+ 2 - 2
app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt

@@ -8,7 +8,6 @@ import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
 import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.viewModels
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
@@ -32,6 +31,7 @@ import io.legado.app.utils.viewbindingdelegate.viewBinding
 import splitties.views.onClick
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
 
 
 /**
@@ -49,7 +49,7 @@ class ImportBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_r
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportBookSourceViewModel>()
+    private val viewModel by viewModel<ImportBookSourceViewModel>()
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 

+ 7 - 4
app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt

@@ -9,9 +9,9 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.config.AppConfig
@@ -30,7 +30,10 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.splitNotBlank
 
 
-class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
+class ImportBookSourceViewModel(
+    app: Application,
+    private val repository: BookSourceRepository,
+) : BaseViewModel(app) {
     var isAddGroup = false
     var groupName: String? = null
     val errorLiveData = MutableLiveData<String>()
@@ -208,7 +211,7 @@ class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allSources.forEach {
-                val source = appDb.bookSourceDao.getBookSourcePart(it.bookSourceUrl)
+                val source = repository.getBookSourcePart(it.bookSourceUrl)
                 checkSources.add(source)
                 selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime)
                 newSourceStatus.add(source == null)
@@ -218,4 +221,4 @@ class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
         }
     }
 
-}
+}

+ 3 - 3
app/src/main/java/io/legado/app/ui/association/ImportDictRuleDialog.kt

@@ -6,7 +6,6 @@ import android.content.DialogInterface
 import android.os.Bundle
 import android.view.View
 import android.view.ViewGroup
-import androidx.fragment.app.viewModels
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
 import io.legado.app.base.BaseDialogFragment
@@ -20,6 +19,7 @@ import io.legado.app.ui.widget.dialog.CodeDialog
 import io.legado.app.ui.widget.dialog.WaitDialog
 import io.legado.app.utils.*
 import io.legado.app.utils.viewbindingdelegate.viewBinding
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import splitties.views.onClick
 
 class ImportDictRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
@@ -33,7 +33,7 @@ class ImportDictRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view)
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportDictRuleViewModel>()
+    private val viewModel by viewModel<ImportDictRuleViewModel>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 
     override fun onStart() {
@@ -182,4 +182,4 @@ class ImportDictRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view)
             }
         }
     }
-}
+}

+ 8 - 5
app/src/main/java/io/legado/app/ui/association/ImportDictRuleViewModel.kt

@@ -7,8 +7,8 @@ import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.DictRule
+import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
@@ -24,7 +24,10 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.readText
 import splitties.init.appCtx
 
-class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) {
+class ImportDictRuleViewModel(
+    app: Application,
+    private val repository: DictRuleRepository,
+) : BaseViewModel(app) {
 
     val errorLiveData = MutableLiveData<String>()
     val successLiveData = MutableLiveData<Int>()
@@ -62,7 +65,7 @@ class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) {
                     selectSource.add(allSources[index])
                 }
             }
-            appDb.dictRuleDao.insert(*selectSource.toTypedArray())
+            repository.insert(*selectSource.toTypedArray())
         }.onFinally {
             finally.invoke()
         }
@@ -115,7 +118,7 @@ class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allSources.forEach {
-                val source = appDb.dictRuleDao.getByName(it.name)
+                val source = repository.findById(it.name)
                 checkSources.add(source)
                 selectStatus.add(source == null)
             }
@@ -123,4 +126,4 @@ class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) {
         }
     }
 
-}
+}

+ 3 - 3
app/src/main/java/io/legado/app/ui/association/ImportHttpTtsDialog.kt

@@ -6,7 +6,6 @@ import android.content.DialogInterface
 import android.os.Bundle
 import android.view.View
 import android.view.ViewGroup
-import androidx.fragment.app.viewModels
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
 import io.legado.app.base.BaseDialogFragment
@@ -23,6 +22,7 @@ import io.legado.app.utils.gone
 import io.legado.app.utils.setLayout
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.viewbindingdelegate.viewBinding
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import io.legado.app.utils.visible
 import splitties.views.onClick
 
@@ -37,7 +37,7 @@ class ImportHttpTtsDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportHttpTtsViewModel>()
+    private val viewModel by viewModel<ImportHttpTtsViewModel>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 
     override fun onStart() {
@@ -187,4 +187,4 @@ class ImportHttpTtsDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
             }
         }
     }
-}
+}

+ 7 - 4
app/src/main/java/io/legado/app/ui/association/ImportHttpTtsViewModel.kt

@@ -9,8 +9,8 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.HttpTTS
+import io.legado.app.data.repository.HttpTtsRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
@@ -24,7 +24,10 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.readText
 import splitties.init.appCtx
 
-class ImportHttpTtsViewModel(app: Application) : BaseViewModel(app) {
+class ImportHttpTtsViewModel(
+    app: Application,
+    private val repository: HttpTtsRepository,
+) : BaseViewModel(app) {
 
     val errorLiveData = MutableLiveData<String>()
     val successLiveData = MutableLiveData<Int>()
@@ -62,7 +65,7 @@ class ImportHttpTtsViewModel(app: Application) : BaseViewModel(app) {
                     selectSource.add(allSources[index])
                 }
             }
-            appDb.httpTTSDao.insert(*selectSource.toTypedArray())
+            repository.insert(*selectSource.toTypedArray())
         }.onFinally {
             finally.invoke()
         }
@@ -120,7 +123,7 @@ class ImportHttpTtsViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allSources.forEach {
-                val source = appDb.httpTTSDao.get(it.id)
+                val source = repository.findById(it.id)
                 checkSources.add(source)
                 selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime)
             }

+ 3 - 3
app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt

@@ -8,7 +8,6 @@ import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
 import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.viewModels
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
 import io.legado.app.base.BaseDialogFragment
@@ -26,6 +25,7 @@ import io.legado.app.ui.widget.dialog.CodeDialog
 import io.legado.app.ui.widget.dialog.WaitDialog
 import io.legado.app.utils.*
 import io.legado.app.utils.viewbindingdelegate.viewBinding
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import splitties.views.onClick
 
 class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
@@ -40,7 +40,7 @@ class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_vi
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportReplaceRuleViewModel>()
+    private val viewModel by viewModel<ImportReplaceRuleViewModel>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 
     override fun onStart() {
@@ -245,4 +245,4 @@ class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_vi
 
     }
 
-}
+}

+ 8 - 5
app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt

@@ -7,8 +7,8 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.ReplaceAnalyzer
 import io.legado.app.help.http.decompressed
@@ -23,7 +23,10 @@ import io.legado.app.utils.readText
 import io.legado.app.utils.splitNotBlank
 import splitties.init.appCtx
 
-class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
+class ImportReplaceRuleViewModel(
+    app: Application,
+    private val repository: ReplaceRuleRepository,
+) : BaseViewModel(app) {
     var isAddGroup = false
     var groupName: String? = null
     val errorLiveData = MutableLiveData<String>()
@@ -76,7 +79,7 @@ class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
                     selectRules.add(rule)
                 }
             }
-            appDb.replaceRuleDao.insert(*selectRules.toTypedArray())
+            repository.insert(*selectRules.toTypedArray())
         }.onFinally {
             finally.invoke()
         }
@@ -130,7 +133,7 @@ class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allRules.forEach {
-                val rule = appDb.replaceRuleDao.findById(it.id)
+                val rule = repository.findById(it.id)
                 checkRules.add(rule)
                 selectStatus.add(rule == null)
             }
@@ -138,4 +141,4 @@ class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
             successLiveData.postValue(allRules.size)
         }
     }
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt

@@ -8,7 +8,6 @@ import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
 import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.viewModels
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
@@ -32,6 +31,7 @@ import io.legado.app.utils.viewbindingdelegate.viewBinding
 import splitties.views.onClick
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
 
 /**
  * 导入rss源弹出窗口
@@ -48,7 +48,7 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportRssSourceViewModel>()
+    private val viewModel by viewModel<ImportRssSourceViewModel>()
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 

+ 7 - 4
app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt

@@ -9,8 +9,8 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
+import io.legado.app.data.repository.RssRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.http.decompressed
@@ -29,7 +29,10 @@ import io.legado.app.utils.readText
 import io.legado.app.utils.splitNotBlank
 import splitties.init.appCtx
 
-class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
+class ImportRssSourceViewModel(
+    app: Application,
+    private val repository: RssRepository,
+) : BaseViewModel(app) {
     var isAddGroup = false
     var groupName: String? = null
     val errorLiveData = MutableLiveData<String>()
@@ -186,7 +189,7 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allSources.forEach {
-                val has = appDb.rssSourceDao.getByKey(it.sourceUrl)
+                val has = repository.getByKey(it.sourceUrl)
                 checkSources.add(has)
                 selectStatus.add(has == null || has.lastUpdateTime < it.lastUpdateTime)
             }
@@ -194,4 +197,4 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
         }
     }
 
-}
+}

+ 3 - 3
app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleDialog.kt

@@ -6,7 +6,6 @@ import android.content.DialogInterface
 import android.os.Bundle
 import android.view.View
 import android.view.ViewGroup
-import androidx.fragment.app.viewModels
 import androidx.recyclerview.widget.LinearLayoutManager
 import io.legado.app.R
 import io.legado.app.base.BaseDialogFragment
@@ -23,6 +22,7 @@ import io.legado.app.utils.gone
 import io.legado.app.utils.setLayout
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.viewbindingdelegate.viewBinding
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import io.legado.app.utils.visible
 import splitties.views.onClick
 
@@ -36,7 +36,7 @@ class ImportTxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_vie
     }
 
     private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val viewModel by viewModels<ImportTxtTocRuleViewModel>()
+    private val viewModel by viewModel<ImportTxtTocRuleViewModel>()
     private val adapter by lazy { SourcesAdapter(requireContext()) }
 
     override fun onStart() {
@@ -177,4 +177,4 @@ class ImportTxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_vie
         }
 
     }
-}
+}

+ 8 - 5
app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleViewModel.kt

@@ -7,8 +7,8 @@ import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
@@ -24,7 +24,10 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.readText
 import splitties.init.appCtx
 
-class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
+class ImportTxtTocRuleViewModel(
+    app: Application,
+    private val repository: TxtTocRuleRepository,
+) : BaseViewModel(app) {
 
     val errorLiveData = MutableLiveData<String>()
     val successLiveData = MutableLiveData<Int>()
@@ -62,7 +65,7 @@ class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
                     selectSource.add(allSources[index])
                 }
             }
-            appDb.txtTocRuleDao.insert(*selectSource.toTypedArray())
+            repository.insert(*selectSource.toTypedArray())
         }.onFinally {
             finally.invoke()
         }
@@ -116,7 +119,7 @@ class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
     private fun comparisonSource() {
         execute {
             allSources.forEach {
-                val source = appDb.txtTocRuleDao.get(it.id)
+                val source = repository.findById(it.id)
                 checkSources.add(source)
                 selectStatus.add(source == null || it != source)
             }
@@ -124,4 +127,4 @@ class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
         }
     }
 
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt

@@ -16,7 +16,6 @@ import android.os.Bundle
 import android.view.Menu
 import android.view.MenuItem
 import androidx.activity.addCallback
-import androidx.activity.viewModels
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.graphics.scale
 import androidx.core.view.HapticFeedbackConstantsCompat
@@ -52,6 +51,7 @@ import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.model.AudioPlay
 import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import io.legado.app.model.BookCover
 import io.legado.app.model.SourceCallBack
 import io.legado.app.service.AudioPlayService
@@ -97,7 +97,7 @@ class AudioPlayActivity :
     private val readAloudSettingsGateway by inject<ReadAloudSettingsGateway>()
 
     override val binding by viewBinding(ActivityAudioPlayBinding::inflate)
-    override val viewModel by viewModels<AudioPlayViewModel>()
+    override val viewModel by viewModel<AudioPlayViewModel>()
     private var adjustProgress = false
     private var playMode = AudioPlay.PlayMode.LIST_END_STOP
     private var playSpeed = 1f

+ 16 - 13
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt

@@ -11,10 +11,10 @@ import org.koin.core.context.GlobalContext
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.help.book.getBookSource
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.simulatedTotalChapterNum
@@ -23,7 +23,10 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 
-class AudioPlayViewModel(application: Application) : BaseViewModel(application) {
+class AudioPlayViewModel(
+    application: Application,
+    private val bookRepository: BookRepository,
+) : BaseViewModel(application) {
     private val otherSettingsGateway get() = GlobalContext.get().get<OtherSettingsGateway>()
     private val readSettingsGateway get() = GlobalContext.get().get<ReadSettingsGateway>()
     val titleData = MutableLiveData<String>()
@@ -32,7 +35,7 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
     fun initData(intent: Intent) = AudioPlay.apply {
         execute {
             val bookUrl = intent.getStringExtra("bookUrl") ?: book?.bookUrl ?: return@execute
-            val book = appDb.bookDao.getBook(bookUrl) ?: return@execute
+            val book = bookRepository.getBook(bookUrl) ?: return@execute
             inBookshelf = intent.getBooleanExtra("inBookshelf", true)
             initBook(book)
         }.onFinally {
@@ -74,12 +77,12 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
             val oldBook = book.copy()
             val cList = WebBook.getChapterListAwait(bookSource, book).getOrThrow()
             if (oldBook.bookUrl == book.bookUrl) {
-                appDb.bookDao.update(book)
+                bookRepository.update(book)
             } else {
-                appDb.bookDao.replace(oldBook, book)
+                bookRepository.replace(oldBook, book)
             }
-            appDb.bookChapterDao.delByBook(book.bookUrl)
-            appDb.bookChapterDao.insert(*cList.toTypedArray())
+            bookRepository.deleteChaptersByBook(book.bookUrl)
+            bookRepository.insertChapters(*cList.toTypedArray())
             AudioPlay.chapterSize = cList.size
             AudioPlay.simulatedChapterSize = book.simulatedTotalChapterNum()
             AudioPlay.upDurChapter()
@@ -107,10 +110,10 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
             )
             book.removeType(BookType.updateError)
             AudioPlay.book?.delete()
-            appDb.bookDao.insert(book)
+            bookRepository.insert(book)
             AudioPlay.book = book
             AudioPlay.bookSource = source
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            bookRepository.insertChapters(*toc.toTypedArray())
             AudioPlay.upDurChapter()
         }.onFinally {
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
@@ -120,7 +123,7 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
     fun removeFromBookshelf(success: (() -> Unit)?) {
         execute {
             AudioPlay.book?.let {
-                appDb.bookDao.delete(it)
+                bookRepository.delete(it)
             }
         }.onSuccess {
             success?.invoke()
@@ -131,11 +134,11 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
         execute {
             book.removeType(BookType.notShelf)
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
 
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            bookRepository.insert(book)
+            bookRepository.insertChapters(*toc.toTypedArray())
         }.onSuccess {
             success?.invoke()
         }.onError {

+ 6 - 6
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -5,8 +5,8 @@ import android.net.Uri
 import androidx.compose.runtime.Stable
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.data.dao.BookmarkDao
 import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
 import io.legado.app.utils.createFileIfNotExist
@@ -79,7 +79,7 @@ sealed interface AllBookmarkEffect {
 
 class AllBookmarkViewModel(
     application: Application,
-    private val bookmarkDao: BookmarkDao
+    private val bookmarkRepository: BookmarkRepository,
 ) : AndroidViewModel(application) {
 
     private val _searchQuery = MutableStateFlow("")
@@ -91,7 +91,7 @@ class AllBookmarkViewModel(
     val uiState: StateFlow<BookmarkUiState> = combine(
         _searchQuery,
         _collapsedGroups,
-        bookmarkDao.flowAll()
+        bookmarkRepository.flowAll()
     ) { query, collapsed, allBookmarks ->
 
         val filteredList = if (query.isBlank()) {
@@ -167,13 +167,13 @@ class AllBookmarkViewModel(
 
     fun updateBookmark(bookmark: Bookmark) {
         viewModelScope.launch(Dispatchers.IO) {
-            bookmarkDao.insert(bookmark)
+            bookmarkRepository.save(bookmark)
         }
     }
 
     fun deleteBookmark(bookmark: Bookmark) {
         viewModelScope.launch(Dispatchers.IO) {
-            bookmarkDao.delete(bookmark)
+            bookmarkRepository.delete(bookmark)
         }
     }
 
@@ -188,7 +188,7 @@ class AllBookmarkViewModel(
                 val fileDoc = dirDoc.createFileIfNotExist(fileName)
 
                 fileDoc.openOutputStream().getOrThrow().use { outputStream ->
-                    val allData = bookmarkDao.all
+                    val allData = bookmarkRepository.getAll()
                     if (isMarkdown) {
                         writeMarkdown(outputStream, allData)
                     } else {

+ 40 - 37
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt

@@ -3,13 +3,11 @@ package io.legado.app.ui.book.cache.manage
 import android.app.Application
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
-import io.legado.app.data.dao.BookChapterDao
-import io.legado.app.data.dao.BookDao
-import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.model.BookChapterCacheInfo
+import io.legado.app.data.repository.BookCacheManageRepository
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.help.book.BookHelp
@@ -108,9 +106,7 @@ sealed interface BookCacheManageEffect {
 
 class BookCacheManageViewModel(
     application: Application,
-    private val bookDao: BookDao,
-    private val bookChapterDao: BookChapterDao,
-    private val bookGroupDao: BookGroupDao,
+    private val repository: BookCacheManageRepository,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
 ) : BaseViewModel(application) {
@@ -164,12 +160,12 @@ class BookCacheManageViewModel(
     private fun initialize() {
         if (observeJob != null) return
         observeJob = viewModelScope.launch {
-            bookDao.flowAll().collect { books ->
+            repository.flowBooks().collect { books ->
                 reloadAll(books = books, forceDatabase = false)
             }
         }
         viewModelScope.launch {
-            bookGroupDao.flowSelect().collect { groups ->
+            repository.flowSelectableGroups().collect { groups ->
                 _uiState.update { state ->
                     state.copy(
                         groups = groups.filter { it.groupId > 0L }
@@ -238,7 +234,11 @@ class BookCacheManageViewModel(
         fullReloadJob = viewModelScope.launch {
             val expandedBookUrls = uiState.value.expandedBookUrls
             val result = withContext(Dispatchers.IO) {
-                val sourceBooks = if (forceDatabase) bookDao.all else books ?: bookDao.all
+                val sourceBooks = if (forceDatabase) {
+                    repository.getAllBooks()
+                } else {
+                    books ?: repository.getAllBooks()
+                }
                 val items = sortItems(
                     sourceBooks
                         .filterNot { it.isLocal || it.isAudio }
@@ -393,7 +393,7 @@ class BookCacheManageViewModel(
     private suspend fun reloadBook(bookUrl: String) {
         val expanded = uiState.value.expandedBookUrls.contains(bookUrl)
         val result = withContext(Dispatchers.IO) {
-            val book = bookDao.getBook(bookUrl)
+            val book = repository.getBook(bookUrl)
             val item = book
                 ?.takeUnless { it.isLocal || it.isAudio }
                 ?.let { buildBookItem(it) }
@@ -434,7 +434,7 @@ class BookCacheManageViewModel(
         }
     }
 
-    private fun buildBookItem(book: Book): BookCacheBookItem? {
+    private suspend fun buildBookItem(book: Book): BookCacheBookItem? {
         val cacheFiles = BookHelp.getChapterFiles(book)
         val bookState = CacheBook.downloadStateFlow.value.books[book.bookUrl]
         val model = CacheBook.cacheBookMap[book.bookUrl]
@@ -451,7 +451,7 @@ class BookCacheManageViewModel(
         val waitingCount = if (isBookPaused) 0 else rawWaitingCount
         val downloadingCount = if (isBookPaused) 0 else rawDownloadingCount
         val errorIndices = errorIndices(book.bookUrl)
-        val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
+        val totalCount = repository.getChapterCount(book.bookUrl)
         val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
         val cachedCount = min(BookHelp.countCachedChapters(book), totalCount)
         if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) {
@@ -477,9 +477,9 @@ class BookCacheManageViewModel(
         return item.cachedFileCount > 0 || item.hasDownloadTask || item.errorCount > 0
     }
 
-    private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> {
-        val book = bookDao.getBook(bookUrl) ?: return emptyList()
-        val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
+    private suspend fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> {
+        val book = repository.getBook(bookUrl) ?: return emptyList()
+        val chapters = repository.getChapterCacheInfo(bookUrl)
         val model = CacheBook.cacheBookMap[bookUrl]
         val bookState = CacheBook.downloadStateFlow.value.books[bookUrl]
         val errorIndices = errorIndices(bookUrl)
@@ -664,32 +664,35 @@ class BookCacheManageViewModel(
         }
     }
 
-    private fun downloadableChapterIndexBatches(
+    private suspend fun downloadableChapterIndexBatches(
         bookUrl: String,
         batchSize: Int = DOWNLOAD_BATCH_SIZE,
-    ): Sequence<List<Int>> = sequence {
-        val book = bookDao.getBook(bookUrl) ?: return@sequence
-        val model = CacheBook.cacheBookMap[bookUrl]
-        var batch = ArrayList<Int>(batchSize)
-        for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
-            if (
-                chapter.isVolume ||
-                    isChapterFullyCached(book, chapter) ||
-                    model?.isPaused(chapter.index) == true ||
-                    model?.isWaiting(chapter.index) == true ||
-                    model?.isDownloading(chapter.index) == true
-            ) {
-                continue
-            }
-            batch.add(chapter.index)
-            if (batch.size == batchSize) {
+    ): Sequence<List<Int>> {
+        val book = repository.getBook(bookUrl) ?: return emptySequence()
+        val chapters = repository.getChapterCacheInfo(bookUrl)
+        return sequence {
+            val model = CacheBook.cacheBookMap[bookUrl]
+            var batch = ArrayList<Int>(batchSize)
+            for (chapter in chapters) {
+                if (
+                    chapter.isVolume ||
+                        isChapterFullyCached(book, chapter) ||
+                        model?.isPaused(chapter.index) == true ||
+                        model?.isWaiting(chapter.index) == true ||
+                        model?.isDownloading(chapter.index) == true
+                ) {
+                    continue
+                }
+                batch.add(chapter.index)
+                if (batch.size == batchSize) {
+                    yield(batch)
+                    batch = ArrayList(batchSize)
+                }
+            }
+            if (batch.isNotEmpty()) {
                 yield(batch)
-                batch = ArrayList(batchSize)
             }
         }
-        if (batch.isNotEmpty()) {
-            yield(batch)
-        }
     }
 
     private fun isChapterFullyCached(
@@ -834,7 +837,7 @@ class BookCacheManageViewModel(
         chapterIndex: Int,
     ) {
         execute {
-            val book = bookDao.getBook(bookUrl) ?: return@execute false
+            val book = repository.getBook(bookUrl) ?: return@execute false
             val chapter = BookChapter(
                 url = chapterUrl,
                 title = chapterTitle,

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt

@@ -6,7 +6,7 @@ import android.view.MenuItem
 import android.view.View
 import androidx.appcompat.widget.Toolbar
 import androidx.core.view.isVisible
-import androidx.fragment.app.viewModels
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import androidx.lifecycle.Lifecycle.State.STARTED
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
@@ -36,7 +36,7 @@ class ChangeCoverDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_change
 
     private val binding by viewBinding(DialogChangeCoverBinding::bind)
     private val callBack: CallBack? get() = activity as? CallBack
-    private val viewModel: ChangeCoverViewModel by viewModels()
+    private val viewModel: ChangeCoverViewModel by viewModel()
     private val adapter by lazy { CoverAdapter(requireContext(), this) }
 
     private val startStopMenuItem: MenuItem?

+ 10 - 5
app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt

@@ -7,10 +7,11 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.mapParallelSafe
@@ -33,7 +34,11 @@ import kotlinx.coroutines.withTimeout
 import java.util.Collections
 import java.util.concurrent.Executors
 
-class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
+class ChangeCoverViewModel(
+    application: Application,
+    private val searchRepository: SearchRepository,
+    private val bookSourceRepository: BookSourceRepository,
+) : BaseViewModel(application) {
     private val threadCount = OtherConfig.threadCount
     private var searchPool: ExecutorCoroutineDispatcher? = null
     private var searchSuccess: ((SearchBook) -> Unit)? = null
@@ -69,7 +74,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
             trySend(defaultCover + searchBooks.sortedBy { it.originOrder })
         }
 
-        appDb.searchBookDao.getEnableHasCover(name, author).let {
+        searchRepository.getEnableHasCover(name, author).let {
             searchBooks.addAll(it)
             trySend(defaultCover + searchBooks.toList())
         }
@@ -112,7 +117,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
             searchBooks.clear()
             upAdapter?.invoke()
             bookSourceParts.clear()
-            bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
+            bookSourceParts.addAll(bookSourceRepository.getAllEnabledPart())
             initSearchPool()
             search()
         }
@@ -152,7 +157,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
         if (searchBook.name == name && searchBook.author == author
             && !searchBook.coverUrl.isNullOrEmpty()
         ) {
-            appDb.searchBookDao.insert(searchBook)
+            searchRepository.saveSearchBook(searchBook)
             searchSuccess?.invoke(searchBook)
         }
     }

+ 15 - 16
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -6,13 +6,13 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.dao.BookDao
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.repository.SearchRepository
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
 import io.legado.app.domain.model.settings.ChangeSourceSettings
 import io.legado.app.domain.usecase.ChangeSourceSearchEvent
@@ -48,13 +48,17 @@ class ChangeBookSourceComposeViewModel(
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
     private val getChapterContentUseCase: GetChapterContentUseCase,
     private val searchRepository: SearchRepository,
-    private val bookDao: BookDao,
+    private val bookRepository: BookRepository,
     private val changeSourceSettingsGateway: ChangeSourceSettingsGateway,
 ) : ViewModel() {
 
     // Public state for the sheet
     val enabledGroups = searchRepository.enabledGroups
-    val enabledSources = searchRepository.enabledSources
+    val enabledSources = searchRepository.enabledSources.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.Eagerly,
+        initialValue = emptyList(),
+    )
 
     val settings = changeSourceSettingsGateway.settings.stateIn(
         viewModelScope,
@@ -104,7 +108,7 @@ class ChangeBookSourceComposeViewModel(
 
     fun findShelfConflict(book: Book, onResult: (Book?) -> Unit) {
         viewModelScope.launch(IO) {
-            val conflict = bookDao.getShelfBookConflict(book.name, book.author)
+            val conflict = bookRepository.getShelfBookConflict(book.name, book.author)
             onMain { onResult(conflict) }
         }
     }
@@ -151,12 +155,10 @@ class ChangeBookSourceComposeViewModel(
         }
     }
 
-    private fun getDbSearchBooks(): List<SearchBook> {
+    private suspend fun getDbSearchBooks(): List<SearchBook> {
         val scope = SearchScope(settings.value.searchScope)
         val author = if (checkAuthor) bookAuthor else ""
-        val dbBooks = io.legado.app.data.appDb.searchBookDao.changeSourceByGroup(
-            bookName, author, ""
-        )
+        val dbBooks = searchRepository.findChangeSourceBooks(bookName, author)
         if (scope.isAll()) return dbBooks
 
         val allowedOrigins = scope.getBookSourceParts()
@@ -173,8 +175,7 @@ class ChangeBookSourceComposeViewModel(
 
     fun startSearch(origin: String) {
         viewModelScope.launch(IO) {
-            val source = io.legado.app.data.appDb.bookSourceDao
-                .getBookSourcePart(origin) ?: return@launch
+            val source = searchRepository.getBookSourcePart(origin) ?: return@launch
             startSearch(listOf(source))
         }
     }
@@ -216,7 +217,7 @@ class ChangeBookSourceComposeViewModel(
         searchJob = viewModelScope.launch(IO) {
             try {
                 if (removedResults.isNotEmpty()) {
-                    io.legado.app.data.appDb.searchBookDao.delete(*removedResults.toTypedArray())
+                    searchRepository.deleteSearchBooks(removedResults)
                 }
                 collectSearchEvents(
                     events = changeSourceSearchUseCase.search(
@@ -374,16 +375,14 @@ class ChangeBookSourceComposeViewModel(
             try {
                 val cachedToc = tocMap[book.primaryStr()]
                 if (cachedToc != null) {
-                    val source =
-                        io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    val source = searchRepository.getBookSource(book.origin)
                     if (source != null) {
                         onMain { onSuccess(cachedToc, source) }
                         return@launch
                     }
                 }
                 if (book.isWebFile) {
-                    val source =
-                        io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    val source = searchRepository.getBookSource(book.origin)
                         ?: throw io.legado.app.exception.NoStackTraceException("书源不存在")
                     onMain { onSuccess(emptyList(), source) }
                     return@launch
@@ -568,7 +567,7 @@ class ChangeBookSourceComposeViewModel(
         if (selectedUrls.isEmpty()) {
             searchScope.update("")
         } else {
-            val selectedSources = io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter {
+            val selectedSources = enabledSources.value.filter {
                 selectedUrls.contains(it.bookSourceUrl)
             }
             searchScope.updateSources(selectedSources)

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt

@@ -13,7 +13,6 @@ import androidx.appcompat.widget.SearchView
 import androidx.appcompat.widget.Toolbar
 import androidx.core.os.bundleOf
 import androidx.core.view.isInvisible
-import androidx.fragment.app.viewModels
 import androidx.lifecycle.Lifecycle.State.STARTED
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
@@ -57,6 +56,7 @@ import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
 
 /**
  * 换源界面
@@ -75,7 +75,7 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
     private val binding by viewBinding(DialogBookChangeSourceBinding::bind)
     private val groups = linkedSetOf<String>()
     private val callBack: CallBack? get() = activity as? CallBack
-    private val viewModel: ChangeBookSourceViewModel by viewModels()
+    private val viewModel: ChangeBookSourceViewModel by viewModel()
     private val changeSourceSettingsGateway by inject<ChangeSourceSettingsGateway>()
     private val waitDialog by lazy { WaitDialog(requireContext()) }
     private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) }
@@ -324,7 +324,7 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
                 val enabled = !item.isChecked
                 updateSetting({ it.copy(checkAuthor = enabled) })
                 item.isChecked = enabled
-                viewModel.refresh()
+                lifecycleScope.launch { viewModel.refresh() }
             }
 
             R.id.menu_load_info -> {

+ 28 - 44
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt

@@ -8,12 +8,13 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
@@ -21,7 +22,6 @@ import io.legado.app.help.book.primaryStr
 import io.legado.app.help.book.releaseHtmlData
 import io.legado.app.help.config.SourceConfig
 import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.internString
@@ -56,7 +56,11 @@ import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.Executors
 
 @Suppress("MemberVisibilityCanBePrivate")
-open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
+open class ChangeBookSourceViewModel(
+    application: Application,
+    private val searchRepository: SearchRepository,
+    private val bookSourceRepository: BookSourceRepository,
+) : BaseViewModel(application) {
     private val threadCount = OtherConfig.threadCount
     private var searchPool: ExecutorCoroutineDispatcher? = null
     val searchStateData = MutableLiveData<Boolean>()
@@ -103,9 +107,9 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
         searchCallback = object : SourceCallback {
 
-            override fun searchSuccess(searchBook: SearchBook) {
+            override suspend fun searchSuccess(searchBook: SearchBook) {
                 searchBook.releaseHtmlData()
-                appDb.searchBookDao.insert(searchBook)
+                searchRepository.saveSearchBook(searchBook)
                 when {
                     screenKey.isEmpty() -> searchBooks.add(searchBook)
                     searchBook.name.contains(screenKey) -> searchBooks.add(searchBook)
@@ -184,7 +188,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
             .newFixedThreadPool(threadCount).asCoroutineDispatcher()
     }
 
-    fun refresh(): Boolean {
+    suspend fun refresh(): Boolean {
         getDbSearchBooks().let {
             searchBooks.clear()
             searchBooks.addAll(it)
@@ -200,7 +204,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         execute {
             stopSearch()
             if (searchBooks.isNotEmpty()) {
-                appDb.searchBookDao.delete(*searchBooks.toTypedArray())
+                searchRepository.deleteSearchBooks(searchBooks.toList())
                 searchBooks.clear()
             }
             searchCallback?.upAdapter()
@@ -222,7 +226,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
             tocMap.clear()
             bookMap.clear()
             tocMapChapterCount = 0
-            bookSourceParts.add(appDb.bookSourceDao.getBookSourcePart(origin)!!)
+            bookSourceParts.add(bookSourceRepository.getBookSourcePart(origin)!!)
             searchBooks.removeIf { it.origin == origin }
             initSearchPool()
             search()
@@ -395,7 +399,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
                 searchStateData.postValue(true)
                 _isSearching.value = true
             }.mapParallelSafe(threadCount) {
-                val source = appDb.bookSourceDao.getBookSource(it.origin)!!
+                val source = bookSourceRepository.getBookSource(it.origin)!!
                 withTimeout(60000L) {
                     loadBookInfo(source, it.toBook())
                 }
@@ -408,7 +412,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         }
     }
 
-    private fun getDbSearchBooks(): List<SearchBook> {
+    private suspend fun getDbSearchBooks(): List<SearchBook> {
         val searchScope = io.legado.app.ui.book.search.SearchScope(
             ChangeSourceConfig.searchScope
         )
@@ -416,19 +420,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
             searchScope.isAll() || searchScope.isSource() -> ""
             else -> searchScope.displayNames.firstOrNull() ?: ""
         }
-        return if (screenKey.isEmpty()) {
-            if (ChangeSourceConfig.checkAuthor) {
-                appDb.searchBookDao.changeSourceByGroup(name, author, group)
-            } else {
-                appDb.searchBookDao.changeSourceByGroup(name, "", group)
-            }
-        } else {
-            if (ChangeSourceConfig.checkAuthor) {
-                appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group)
-            } else {
-                appDb.searchBookDao.changeSourceSearch(name, "", screenKey, group)
-            }
-        }
+        val bookAuthor = if (ChangeSourceConfig.checkAuthor) author else ""
+        return searchRepository.findChangeSourceBooks(name, bookAuthor, screenKey, group)
     }
 
     /**
@@ -479,7 +472,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         return execute {
             val toc = tocMap[book.primaryStr()]
             if (toc != null) {
-                val source = appDb.bookSourceDao.getBookSource(book.origin)
+                val source = bookSourceRepository.getBookSource(book.origin)
                 return@execute Pair(toc, source!!)
             }
             val result = getToc(book).getOrThrow()
@@ -494,7 +487,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     suspend fun getToc(book: Book): Result<Pair<List<BookChapter>, BookSource>> {
         return kotlin.runCatching {
-            val source = appDb.bookSourceDao.getBookSource(book.origin)
+            val source = bookSourceRepository.getBookSource(book.origin)
                 ?: throw NoStackTraceException("书源不存在")
             if (book.tocUrl.isEmpty()) {
                 WebBook.getBookInfoAwait(source, book)
@@ -506,10 +499,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     fun disableSource(searchBook: SearchBook) {
         execute {
-            appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
-                source.enabled = false
-                appDb.bookSourceDao.update(source)
-            }
+            bookSourceRepository.disableSource(searchBook.origin)
             searchBooks.remove(searchBook)
             searchCallback?.upAdapter()
         }
@@ -517,11 +507,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     fun topSource(searchBook: SearchBook) {
         execute {
-            appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
-                val minOrder = appDb.bookSourceDao.minOrder - 1
-                source.customOrder = minOrder
-                searchBook.originOrder = source.customOrder
-                appDb.bookSourceDao.update(source)
+            bookSourceRepository.moveSourceToTop(searchBook.origin)?.let { customOrder ->
+                searchBook.originOrder = customOrder
                 updateSource(searchBook)
             }
             searchCallback?.upAdapter()
@@ -530,25 +517,22 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     fun bottomSource(searchBook: SearchBook) {
         execute {
-            appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
-                val maxOrder = appDb.bookSourceDao.maxOrder + 1
-                source.customOrder = maxOrder
-                searchBook.originOrder = source.customOrder
-                appDb.bookSourceDao.update(source)
+            bookSourceRepository.moveSourceToBottom(searchBook.origin)?.let { customOrder ->
+                searchBook.originOrder = customOrder
                 updateSource(searchBook)
             }
             searchCallback?.upAdapter()
         }
     }
 
-    fun updateSource(searchBook: SearchBook) {
-        appDb.searchBookDao.update(searchBook)
+    private suspend fun updateSource(searchBook: SearchBook) {
+        searchRepository.updateSearchBook(searchBook)
     }
 
     fun del(searchBook: SearchBook) {
         execute {
-            SourceHelp.deleteBookSource(searchBook.origin)
-            appDb.searchBookDao.delete(searchBook)
+            bookSourceRepository.deleteSource(searchBook.origin)
+            searchRepository.deleteSearchBooks(listOf(searchBook))
         }
         searchBooks.remove(searchBook)
         searchCallback?.upAdapter()
@@ -592,7 +576,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     interface SourceCallback {
 
-        fun searchSuccess(searchBook: SearchBook)
+        suspend fun searchSuccess(searchBook: SearchBook)
 
         fun upAdapter()
 

+ 10 - 18
app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt

@@ -125,7 +125,7 @@ class ChangeChapterSourceViewModel(
         }
     }
 
-    private fun getDbSearchBooks(): List<SearchBook> {
+    private suspend fun getDbSearchBooks(): List<SearchBook> {
         val name = oldBook?.name ?: return emptyList()
         val author = oldBook?.author ?: return emptyList()
         val settings = changeSourceSettingsGateway.currentSettings
@@ -138,15 +138,7 @@ class ChangeChapterSourceViewModel(
             }
         }
         val bookAuthor = if (settings.checkAuthor) author else ""
-        return if (screenKey.isEmpty()) {
-            io.legado.app.data.appDb.searchBookDao.changeSourceByGroup(
-                name, bookAuthor, group
-            )
-        } else {
-            io.legado.app.data.appDb.searchBookDao.changeSourceSearch(
-                name, bookAuthor, screenKey, group
-            )
-        }
+        return searchRepository.findChangeSourceBooks(name, bookAuthor, screenKey, group)
     }
 
     fun onIntent(intent: ChangeChapterSourceIntent) {
@@ -277,10 +269,9 @@ class ChangeChapterSourceViewModel(
                 if (selectedUrls.isEmpty()) {
                     searchScope.update("")
                 } else {
-                    val selectedSources =
-                        io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter {
-                            selectedUrls.contains(it.bookSourceUrl)
-                        }
+                    val selectedSources = _uiState.value.enabledSources.filter {
+                        selectedUrls.contains(it.bookSourceUrl)
+                    }
                     searchScope.updateSources(selectedSources)
                 }
                 updateScopeState()
@@ -297,9 +288,7 @@ class ChangeChapterSourceViewModel(
     private fun startSearch() {
         val book = oldBook ?: return
         stopSearch()
-        if (searchResults.isNotEmpty()) {
-            io.legado.app.data.appDb.searchBookDao.delete(*searchResults.toTypedArray())
-        }
+        val removedResults = searchResults.toList()
         searchResults.clear()
         bookMap.clear()
         val scope = SearchScope(changeSourceSettingsGateway.currentSettings.searchScope)
@@ -312,6 +301,9 @@ class ChangeChapterSourceViewModel(
         filterResults()
 
         searchJob = viewModelScope.launch {
+            if (removedResults.isNotEmpty()) {
+                searchRepository.deleteSearchBooks(removedResults)
+            }
             changeSourceSearchUseCase.search(
                 name = book.name,
                 author = book.author,
@@ -336,7 +328,7 @@ class ChangeChapterSourceViewModel(
                     is ChangeSourceSearchEvent.Result -> {
                         searchResults.add(event.searchBook)
                         bookMap[event.searchBook.primaryStr()] = event.searchBook
-                        io.legado.app.data.appDb.searchBookDao.insert(event.searchBook)
+                        searchRepository.saveSearchBook(event.searchBook)
                         filterResults()
                     }
 

+ 13 - 19
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -13,14 +13,12 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.archiveFileRegex
 import io.legado.app.constant.AppPattern.bookFileRegex
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.BookImportRepository
 import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.model.settings.ImportBookSettings
 import io.legado.app.domain.model.settings.OtherSettings
-import io.legado.app.help.book.BookHelp
-import io.legado.app.help.book.canSafelyRebindTo
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.ListUiState
@@ -118,6 +116,7 @@ class ImportBookViewModel(
     application: Application,
     private val importBookSettingsGateway: ImportBookSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
+    private val repository: BookImportRepository,
 ) : BaseViewModel(application) {
 
     private enum class SourceMode {
@@ -191,7 +190,7 @@ class ImportBookViewModel(
 
     val uiState = combine(
         _state,
-        appDb.bookDao.flowLocal(),
+        repository.flowLocalBooks(),
         importBookSettingsGateway.settings,
     ) { state, localBooks, settings ->
         val localFileNames = localBooks.asSequence().map { it.originName }.toSet()
@@ -605,19 +604,10 @@ class ImportBookViewModel(
 
     private fun onImportedFileClick(fileDoc: FileDoc) {
         if (!ArchiveUtils.isArchive(fileDoc.name)) {
-            appDb.bookDao.getBookByFileName(fileDoc.name)?.let { book ->
-                val filePath = fileDoc.toString()
-                val bookToOpen = if (book.bookUrl != filePath && book.canSafelyRebindTo(filePath)) {
-                    val reboundBook = book.copy(bookUrl = filePath)
-                    appDb.runInTransaction {
-                        appDb.bookDao.replace(book, reboundBook)
-                        BookHelp.updateCacheFolder(book, reboundBook)
-                    }
-                    reboundBook
-                } else {
-                    book
+            viewModelScope.launch {
+                repository.findAndRebind(fileDoc.name, fileDoc.toString())?.let { book ->
+                    _effects.tryEmit(ImportBookEffect.OpenBook(book))
                 }
-                _effects.tryEmit(ImportBookEffect.OpenBook(bookToOpen))
             }
             return
         }
@@ -635,9 +625,13 @@ class ImportBookViewModel(
     }
 
     private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) {
-        appDb.bookDao.getBookByFileName(fileName)?.let {
-            _effects.tryEmit(ImportBookEffect.OpenBook(it))
-        } ?: _effects.tryEmit(ImportBookEffect.ShowImportArchiveDialog(fileDoc, fileName))
+        viewModelScope.launch {
+            val book = repository.findByFileName(fileName)
+            _effects.tryEmit(
+                book?.let(ImportBookEffect::OpenBook)
+                    ?: ImportBookEffect.ShowImportArchiveDialog(fileDoc, fileName)
+            )
+        }
     }
 
     private fun addArchiveToBookShelf(fileDoc: FileDoc, fileName: String) {

+ 9 - 8
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt

@@ -9,9 +9,9 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Server
+import io.legado.app.data.repository.BookImportRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -113,6 +113,7 @@ class RemoteBookViewModel(
     @Suppress("UNUSED_PARAMETER")
     private val savedStateHandle: SavedStateHandle,
     private val repository: RemoteBookRepository,
+    private val bookImportRepository: BookImportRepository,
     private val importBookSettingsGateway: ImportBookSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
 ) : BaseViewModel(application) {
@@ -314,9 +315,7 @@ class RemoteBookViewModel(
     }
 
     suspend fun getLocalBook(fileName: String): Book? {
-        return withContext(Dispatchers.IO) {
-            appDb.bookDao.getBookByFileName(fileName)
-        }
+        return bookImportRepository.findByFileName(fileName)
     }
 
     fun navigateToDir(book: RemoteBook) {
@@ -466,10 +465,12 @@ class RemoteBookViewModel(
     }
 
     private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) {
-        appDb.bookDao.getBookByFileName(fileName)?.let { book ->
-            viewModelScope.launch { _effects.emit(RemoteBookEffect.OpenBook(book)) }
-        } ?: viewModelScope.launch {
-            _effects.emit(RemoteBookEffect.ShowImportArchiveDialog(fileDoc, fileName))
+        viewModelScope.launch {
+            val book = bookImportRepository.findByFileName(fileName)
+            _effects.emit(
+                book?.let(RemoteBookEffect::OpenBook)
+                    ?: RemoteBookEffect.ShowImportArchiveDialog(fileDoc, fileName)
+            )
         }
     }
 

+ 46 - 39
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -15,7 +15,6 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
@@ -23,8 +22,12 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.HighlightTagRuleRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
@@ -102,6 +105,10 @@ class BookInfoViewModel(
     private val changeBookSourceUseCase: ChangeBookSourceUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
     private val bookGroupRepository: BookGroupRepository,
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository,
+    private val searchRepository: SearchRepository,
+    private val highlightTagRuleRepository: HighlightTagRuleRepository,
     private val imageLoader: ImageLoader,
     private val bookKnowledgeGateway: BookKnowledgeGateway,
     private val themeSettingsGateway: ThemeSettingsGateway,
@@ -230,12 +237,12 @@ class BookInfoViewModel(
         characterLoadJob?.cancel()
         syncUiState()
         execute {
-            val dbBook = appDb.bookDao.getBook(bookUrl)
+            val dbBook = bookRepository.getBook(bookUrl)
             if (dbBook != null) {
                 inBookshelf = !dbBook.isNotShelf
                 dbBook
             } else {
-                val searchBook = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()
+                val searchBook = searchRepository.getSearchBook(bookUrl)?.toBook()
                 if (searchBook != null) {
                     inBookshelf = false
                     searchBook
@@ -251,7 +258,7 @@ class BookInfoViewModel(
             val source = if (book.isLocal) {
                 null
             } else {
-                appDb.bookSourceDao.getBookSource(book.origin)
+                bookSourceRepository.getBookSource(book.origin)
             }
             upBook(book, source)
         }.onError {
@@ -382,7 +389,7 @@ class BookInfoViewModel(
 
     fun onSourceEdited() {
         currentBook?.let { book ->
-            bookSource = appDb.bookSourceDao.getBookSource(book.origin)
+            bookSource = bookSourceRepository.getBookSourceSync(book.origin)
             syncUiState()
             refreshBook(book)
         }
@@ -391,11 +398,11 @@ class BookInfoViewModel(
     fun onInfoEdited() {
         currentBook?.bookUrl?.let { bookUrl ->
             execute {
-                val book = appDb.bookDao.getBook(bookUrl) ?: return@execute null
+                val book = bookRepository.getBook(bookUrl) ?: return@execute null
                 val source = if (book.isLocal) {
                     null
                 } else {
-                    appDb.bookSourceDao.getBookSource(book.origin)
+                    bookSourceRepository.getBookSource(book.origin)
                 }
                 book to source
             }.onSuccess {
@@ -416,7 +423,7 @@ class BookInfoViewModel(
         execute {
             book.durChapterIndex = result.first
             book.durChapterPos = result.second
-            appDb.bookDao.update(book)
+            bookRepository.update(book)
             book
         }.onSuccess {
             currentBook = it
@@ -441,7 +448,7 @@ class BookInfoViewModel(
     fun refreshShelfState() {
         val bookUrl = currentBook?.bookUrl ?: return
         execute {
-            appDb.bookDao.getBook(bookUrl)
+            bookRepository.getBook(bookUrl)
         }.onSuccess { dbBook ->
             val nextInBookshelf = dbBook != null && !dbBook.isNotShelf
             if (nextInBookshelf) {
@@ -540,10 +547,10 @@ class BookInfoViewModel(
     fun topBook() {
         currentBook?.let { book ->
             execute {
-                val minOrder = appDb.bookDao.minOrder
+                val minOrder = bookRepository.getMinOrder()
                 book.order = minOrder - 1
                 book.durChapterTime = System.currentTimeMillis()
-                appDb.bookDao.update(book)
+                bookRepository.update(book)
                 book
             }.onSuccess {
                 currentBook = it
@@ -558,8 +565,8 @@ class BookInfoViewModel(
         execute {
             setBusy(true)
             val newBook = remoteBookRepository.syncBookFromRemote(book)
-            appDb.bookDao.delete(book)
-            appDb.bookDao.insert(newBook)
+            bookRepository.delete(book)
+            bookRepository.insert(newBook)
             newBook
         }.onSuccess { newBook ->
             currentBook = newBook
@@ -660,16 +667,16 @@ class BookInfoViewModel(
         book ?: return
         execute {
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
-            appDb.bookDao.getBook(book.name, book.author)?.let {
+            bookRepository.getBook(book.name, book.author)?.let {
                 book.durChapterIndex = it.durChapterIndex
                 book.durChapterPos = it.durChapterPos
                 book.durChapterTitle = it.durChapterTitle
             }
             book.save()
-            if (ReadBook.book?.isSameNameAuthor(book) == true) {
-                ReadBook.book = book
+            if (ReadBook.isCurrentBook(book)) {
+                ReadBook.replaceCurrentBook(book)
             } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
                 AudioPlay.book = book
             }
@@ -685,7 +692,7 @@ class BookInfoViewModel(
 
     fun saveChapterList(success: (() -> Unit)? = null) {
         execute {
-            appDb.bookChapterDao.insert(*currentChapterList.toTypedArray())
+            bookRepository.insertChapters(*currentChapterList.toTypedArray())
         }.onSuccess {
             success?.invoke()
         }
@@ -696,21 +703,21 @@ class BookInfoViewModel(
         execute {
             book.removeType(BookType.notShelf)
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
-            appDb.bookDao.getBook(book.name, book.author)?.let {
+            bookRepository.getBook(book.name, book.author)?.let {
                 book.durChapterIndex = it.durChapterIndex
                 book.durChapterPos = it.durChapterPos
                 book.durChapterTitle = it.durChapterTitle
             }
-            if (ReadBook.book?.isSameNameAuthor(book) == true) {
-                ReadBook.book = book
+            if (ReadBook.isCurrentBook(book)) {
+                ReadBook.replaceCurrentBook(book)
             } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
                 AudioPlay.book = book
             }
             book.save()
             SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book)
-            appDb.bookChapterDao.insert(*currentChapterList.toTypedArray())
+            bookRepository.insertChapters(*currentChapterList.toTypedArray())
             book
         }.onSuccess {
             currentBook = it
@@ -724,10 +731,10 @@ class BookInfoViewModel(
         execute {
             book.removeType(BookType.notShelf)
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            bookRepository.insert(book)
+            bookRepository.insertChapters(*toc.toTypedArray())
             book
         }.onSuccess {
             if (currentBook?.bookUrl == it.bookUrl) {
@@ -806,7 +813,7 @@ class BookInfoViewModel(
             }
             WebBook.getBookInfo(scope, source, book, canReName = canReName)
                 .onSuccess(IO) { loadedBook ->
-                    val dbBook = appDb.bookDao.getBook(loadedBook.name, loadedBook.author)
+                    val dbBook = bookRepository.getBook(loadedBook.name, loadedBook.author)
                     if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == loadedBook.origin) {
                         dbBook.updateTo(loadedBook)
                         inBookshelf = true
@@ -888,7 +895,7 @@ class BookInfoViewModel(
             loadBookInfo(book, runPreUpdateJs = inBookshelf, showLoading = false)
         } else {
             execute {
-                appDb.bookChapterDao.getChapterList(book.bookUrl)
+                bookRepository.getChapters(book.bookUrl)
             }.onSuccess { chapters ->
                 if (chapters.isNotEmpty()) {
                     currentChapterList = chapters
@@ -926,14 +933,14 @@ class BookInfoViewModel(
     private fun refreshMeta(book: Book) {
         execute {
             book.upKind()
-            val userGroupIds = appDb.bookGroupDao.idsSum
+            val userGroupIds = bookGroupRepository.getIdsSum()
             val groupAnd = userGroupIds and book.group
             val hasCustomGroup = book.group > 0L && groupAnd != 0L
-            val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
+            val groupNames = bookGroupRepository.getGroupNames(book.group).joinToString(",")
             val normalizedGroupNames = groupNames.ifBlank { null }
-            appDb.bookDao.update(book)
+            bookRepository.update(book)
             val finalKinds = book.getDisplayTagList()
-            val enabledRules = appDb.highlightTagRuleDao.getEnabled()
+            val enabledRules = highlightTagRuleRepository.getEnabled()
             val (highlighted, regular) = parseHighlightedTags(finalKinds, enabledRules)
             HighlightMeta(highlighted, regular, normalizedGroupNames, hasCustomGroup)
         }.onSuccess {
@@ -963,9 +970,9 @@ class BookInfoViewModel(
         if (book.isLocal) {
             execute(scope) {
                 LocalBook.getChapterList(book).also {
-                    appDb.bookDao.update(book)
-                    appDb.bookChapterDao.delByBook(book.bookUrl)
-                    appDb.bookChapterDao.insert(*it.toTypedArray())
+                    bookRepository.update(book)
+                    bookRepository.deleteChaptersByBook(book.bookUrl)
+                    bookRepository.insertChapters(*it.toTypedArray())
                     ReadBook.onChapterListUpdated(book)
                 }
             }.onSuccess {
@@ -988,12 +995,12 @@ class BookInfoViewModel(
             WebBook.getChapterList(scope, source, book, runPreUpdateJs)
                 .onSuccess(IO) { chapters ->
                     if (inBookshelf) {
-                        appDb.bookDao.replace(oldBook, book)
+                        bookRepository.replace(oldBook, book)
                         if (oldBook.bookUrl != book.bookUrl) {
                             BookHelp.updateCacheFolder(oldBook, book)
                         }
-                        appDb.bookChapterDao.delByBook(oldBook.bookUrl)
-                        appDb.bookChapterDao.insert(*chapters.toTypedArray())
+                        bookRepository.deleteChaptersByBook(oldBook.bookUrl)
+                        bookRepository.insertChapters(*chapters.toTypedArray())
                         ReadBook.onChapterListUpdated(book)
                     }
                     currentBook = book
@@ -1269,7 +1276,7 @@ class BookInfoViewModel(
     private fun onOriginClick() {
         val book = currentBook ?: return
         if (book.isLocal) return
-        if (!appDb.bookSourceDao.has(book.origin)) {
+        if (!bookSourceRepository.has(book.origin)) {
             showMessage(R.string.error_no_source)
             return
         }

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt

@@ -1,7 +1,7 @@
 package io.legado.app.ui.book.info.edit
 
 import android.os.Bundle
-import androidx.activity.viewModels
+import org.koin.androidx.viewmodel.ext.android.viewModel
 import androidx.compose.runtime.Composable
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
@@ -9,7 +9,7 @@ import io.legado.app.ui.main.MainActivity
 
 class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack {
 
-    private val viewModel by viewModels<BookInfoEditViewModel>()
+    private val viewModel by viewModel<BookInfoEditViewModel>()
 
     @Composable
     override fun Content() {

+ 9 - 6
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt

@@ -7,8 +7,8 @@ import android.net.Uri
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.addType
 import io.legado.app.help.book.applyTagGroupRulesForBook
@@ -48,14 +48,17 @@ data class BookInfoEditUiState(
     val book: Book? = null,
 )
 
-class BookInfoEditViewModel(application: Application) : BaseViewModel(application) {
+class BookInfoEditViewModel(
+    application: Application,
+    private val bookRepository: BookRepository,
+) : BaseViewModel(application) {
     var book: Book? = null
     private val _uiState = MutableStateFlow(BookInfoEditUiState())
     val uiState: StateFlow<BookInfoEditUiState> = _uiState.asStateFlow()
 
     fun loadBook(bookUrl: String) {
         execute {
-            book = appDb.bookDao.getBook(bookUrl)
+            book = bookRepository.getBook(bookUrl)
             book?.let {
                 val selectedType = when {
                     it.isImage -> BookInfoEditType.IMAGE
@@ -144,10 +147,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                 applyTagGroupRulesForBook(book)
                 BookHelp.updateCacheFolder(oldBook, book)
 
-                if (ReadBook.book?.bookUrl == book.bookUrl) {
-                    ReadBook.book = book
+                if (ReadBook.isCurrentBook(book.bookUrl)) {
+                    ReadBook.replaceCurrentBook(book)
                 }
-                appDb.bookDao.update(book)
+                bookRepository.update(book)
             }
         }.onSuccess {
             onSuccess.invoke()

+ 21 - 18
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -10,11 +10,12 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book.ReadConfig
 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.domain.model.ReadingProgress
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -59,6 +60,8 @@ class ReadMangaViewModel(
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val readSettingsGateway: ReadSettingsGateway,
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository,
 ) : BaseViewModel(application) {
 
     private val _mangaSettings = MutableStateFlow(mangaSettingsGateway.currentSettings)
@@ -85,8 +88,8 @@ class ReadMangaViewModel(
             ReadManga.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
             val bookUrl = intent.getStringExtra("bookUrl")
             val book = when {
-                bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
-                else -> appDb.bookDao.getBook(bookUrl)
+                bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+                else -> bookRepository.getBook(bookUrl)
             } ?: ReadManga.book
             when {
                 book != null -> initManga(book)
@@ -150,7 +153,7 @@ class ReadMangaViewModel(
     fun upBookSource(success: (() -> Unit)?) {
         execute {
             ReadManga.book?.let { book ->
-                ReadManga.bookSource = appDb.bookSourceDao.getBookSource(book.origin)
+                ReadManga.bookSource = bookSourceRepository.getBookSource(book.origin)
             }
         }.onSuccess {
             success?.invoke()
@@ -167,7 +170,7 @@ class ReadMangaViewModel(
         execute {
             ReadManga.bookSource?.let {
                 it.enabled = false
-                appDb.bookSourceDao.update(it)
+                bookSourceRepository.updateSources(it)
             }
         }
     }
@@ -177,13 +180,13 @@ class ReadMangaViewModel(
             val oldBook = book.copy()
             WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
                 if (oldBook.bookUrl == book.bookUrl) {
-                    appDb.bookDao.update(book)
+                    bookRepository.update(book)
                 } else {
-                    appDb.bookDao.replace(oldBook, book)
+                    bookRepository.replace(oldBook, book)
                     BookHelp.updateCacheFolder(oldBook, book)
                 }
-                appDb.bookChapterDao.delByBook(oldBook.bookUrl)
-                appDb.bookChapterDao.insert(*cList.toTypedArray())
+                bookRepository.deleteChaptersByBook(oldBook.bookUrl)
+                bookRepository.insertChapters(*cList.toTypedArray())
                 ReadManga.onChapterListUpdated(book)
                 return true
             }.onFailure {
@@ -217,7 +220,7 @@ class ReadMangaViewModel(
     private fun autoChangeSource(name: String, author: String) {
         if (!GlobalReadConfig.autoChangeSource) return
         execute {
-            val sources = appDb.bookSourceDao.allTextEnabledPart
+            val sources = bookSourceRepository.getAllTextEnabledPart()
             flow {
                 for (source in sources) {
                     source.getBookSource()?.let {
@@ -309,8 +312,8 @@ class ReadMangaViewModel(
             )
             book.removeType(BookType.updateError)
             ReadManga.book?.delete()
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            bookRepository.insert(book)
+            bookRepository.insertChapters(*toc.toTypedArray())
             ReadManga.resetData(book)
             ReadManga.loadContent()
         }.onError {
@@ -411,7 +414,7 @@ class ReadMangaViewModel(
         book.readConfig = config
 
         execute {
-            appDb.bookDao.update(book)
+            bookRepository.update(book)
         }.onError {
             AppLog.put("更新阅读配置失败\n${it.localizedMessage}", it)
         }
@@ -424,7 +427,7 @@ class ReadMangaViewModel(
 
     fun refreshContentDur(book: Book) {
         execute {
-            appDb.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)
+            bookRepository.getChapter(book.bookUrl, ReadManga.durChapterIndex)
                 ?.let { chapter ->
                     BookHelp.delContent(book, chapter)
                     openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos)
@@ -438,7 +441,7 @@ class ReadMangaViewModel(
     ) {
         val book = ReadManga.book ?: return onFailure()
         execute {
-            val toc = appDb.bookChapterDao.getChapterList(book.bookUrl)
+            val toc = bookRepository.getChapters(book.bookUrl)
             persistOnBookshelf(book, toc)
             ReadManga.inBookshelf = true
         }.onSuccess {
@@ -464,9 +467,9 @@ class ReadMangaViewModel(
     private suspend fun persistOnBookshelf(book: Book, toc: List<BookChapter>) {
         book.removeType(BookType.notShelf)
         if (book.order == 0) {
-            book.order = appDb.bookDao.minOrder - 1
+            book.order = bookRepository.getMinOrder() - 1
         }
-        appDb.bookDao.insert(book)
-        appDb.bookChapterDao.insert(*toc.toTypedArray())
+        bookRepository.insert(book)
+        bookRepository.insertChapters(*toc.toTypedArray())
     }
 }

+ 91 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAiContract.kt

@@ -0,0 +1,91 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * AI 域(章节摘要 / 划词净化 / 划词重写 / 重写预设)的状态契约。
+ *
+ * 由 [ReadAiDelegate] 独立持有,不再挂在 [ReadBookUiState] 上——AI 流式输出每秒刷新多次,
+ * 挂在阅读态上会让整个 ReadBookUiState 反复 copy。
+ * sheet 的开合仍由 [ReadBookUiState.activeSheet] 单一持有,保持互斥与返回键行为不变。
+ */
+@Stable
+data class ReadAiUiState(
+    val chapterSummary: ChapterSummaryUiState = ChapterSummaryUiState(),
+    val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
+    val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
+    val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+)
+
+@Stable
+data class ChapterSummaryUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val summary: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiTextCleanUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val isApplying: Boolean = false,
+    val originalText: String = "",
+    val replacementText: String = "",
+    val streamingText: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiRewritePresetUi(
+    val id: String,
+    val name: String,
+    val instruction: String,
+)
+
+@Stable
+data class AiRewriteHistoryUi(
+    val artifactId: String,
+    val text: String,
+    val timeText: String,
+)
+
+@Stable
+data class AiTextRewriteUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val isApplying: Boolean = false,
+    val originalText: String = "",
+    val rewrittenText: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val selectedPresetId: String = "",
+    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
+    val temporaryInstruction: String = "",
+    val history: ImmutableList<AiRewriteHistoryUi> = persistentListOf(),
+    val referenceCount: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiRewritePresetConfigUiState(
+    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
+    val editing: Boolean = false,
+    val editingPresetId: String? = null,
+    val editingName: String = "",
+    val editingInstruction: String = "",
+    val deletePreset: AiRewritePresetUi? = null,
+    val errorMessage: String? = null,
+)

+ 1352 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -0,0 +1,1352 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.data.entities.AiPromptPreset
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.domain.gateway.AiArtifactGateway
+import io.legado.app.domain.gateway.AiPromptPresetGateway
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.usecase.AiTextFactoryUseCase
+import io.legado.app.domain.usecase.CleanSelectedTextUseCase
+import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
+import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.model.translation.TranslationManager
+import io.legado.app.utils.MD5Utils
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Date
+import java.util.UUID
+import kotlin.coroutines.coroutineContext
+
+/**
+ * 阅读页 AI 域:章节摘要、划词净化、划词重写、重写预设配置。
+ *
+ * 自持 [ReadAiUiState],与阅读态解耦;只通过 [Host] 反向触达阅读页的
+ * sheet 开合、菜单关闭、正文重载与 Toast。
+ *
+ * 章节读取仍走 [Host]——AI 域不自带 DAO 直连,避免把 `legacyDaoInjectionBaseline`
+ * 的 ViewModel 棘轮债洗进宽松的 `legacyUiDaoAccessBaseline`。这几处随 R2.1
+ * 的 `ReaderSession` 溶解一并清理。
+ */
+class ReadAiDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val generateChapterSummaryUseCase: GenerateChapterSummaryUseCase,
+    private val cleanSelectedTextUseCase: CleanSelectedTextUseCase,
+    private val aiTextFactoryUseCase: AiTextFactoryUseCase,
+    private val saveBookContentProcessUseCase: SaveBookContentProcessUseCase,
+    private val aiArtifactGateway: AiArtifactGateway,
+    private val aiPromptPresetGateway: AiPromptPresetGateway,
+) {
+
+    /** AI 域对阅读页的全部反向依赖,实现在 `ReadBookViewModel`。 */
+    interface Host {
+        /** 当前活动 sheet,用于判断流式回调是否还属于当前会话。 */
+        val activeSheet: ReadBookSheet?
+
+        /** 当前章节名,开摘要/净化时作为标题回退值。 */
+        val chapterName: String
+
+        fun setActiveSheet(sheet: ReadBookSheet?)
+
+        fun closeReadMenu()
+
+        fun showToast(message: String)
+
+        fun reloadChapterAfterContentProcessChanged(bookUrl: String, chapterIndex: Int)
+
+        suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
+
+        suspend fun listChapters(bookUrl: String): List<BookChapter>
+    }
+
+    private val _uiState = MutableStateFlow(ReadAiUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private var chapterSummaryJob: Job? = null
+    private var aiTextCleanJob: Job? = null
+    private var aiTextRewriteJob: Job? = null
+    private var pendingAiTextCleanRequest: PendingAiTextCleanRequest? = null
+    private var pendingAiTextRewriteRequest: PendingAiTextRewriteRequest? = null
+
+    /**
+     * 关闭 sheet 时的收尾:取消在途任务并清空对应子状态。
+     * `activeSheet` 本身由 `ReadBookViewModel` 统一置空。
+     */
+    fun onSheetDismissed(sheet: ReadBookSheet?) {
+        when (sheet) {
+            is ReadBookSheet.ChapterSummary -> {
+                chapterSummaryJob?.cancel()
+                _uiState.update { it.copy(chapterSummary = ChapterSummaryUiState()) }
+            }
+
+            is ReadBookSheet.AiTextClean -> {
+                aiTextCleanJob?.cancel()
+                pendingAiTextCleanRequest = null
+                _uiState.update { it.copy(aiTextClean = AiTextCleanUiState()) }
+            }
+
+            is ReadBookSheet.AiRewritePresetConfig -> {
+                _uiState.update { it.copy(aiRewritePresetConfig = AiRewritePresetConfigUiState()) }
+            }
+
+            else -> Unit
+        }
+    }
+
+    // --- 章节摘要 ---
+
+    fun openChapterSummary() {
+        val book = ReadBook.book ?: return
+        val chapterIndex = ReadBook.durChapterIndex
+        val chapterTitle = host.chapterName
+        host.closeReadMenu()
+        _uiState.update {
+            it.copy(
+                chapterSummary = ChapterSummaryUiState(
+                    bookUrl = book.bookUrl,
+                    chapterIndex = chapterIndex,
+                    chapterTitle = chapterTitle,
+                    isLoading = true,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.ChapterSummary)
+        generateChapterSummary(book.bookUrl, chapterIndex)
+    }
+
+    fun retryChapterSummary() {
+        val summary = _uiState.value.chapterSummary
+        if (summary.bookUrl.isBlank() || summary.chapterIndex < 0) return
+        _uiState.update {
+            it.copy(
+                chapterSummary = summary.copy(
+                    isLoading = true,
+                    summary = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateChapterSummary(summary.bookUrl, summary.chapterIndex)
+    }
+
+    private fun generateChapterSummary(bookUrl: String, chapterIndex: Int) {
+        chapterSummaryJob?.cancel()
+        chapterSummaryJob = scope.launch {
+            val book = ReadBook.book
+            if (book == null || book.bookUrl != bookUrl) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.ai_chapter_changed),
+                )
+                return@launch
+            }
+            val chapter = withContext(IO) {
+                host.findChapter(bookUrl, chapterIndex)
+            }
+            if (chapter == null) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.no_chapter),
+                )
+                return@launch
+            }
+            val content = withContext(IO) {
+                getEffectiveChapterContent(book, chapter)
+            }
+            if (content.isBlank()) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.ai_chapter_content_unavailable),
+                )
+                return@launch
+            }
+            try {
+                withContext(IO) {
+                    generateChapterSummaryUseCase.start(
+                        book = book,
+                        bookChapter = chapter,
+                        contentOverride = content,
+                    )
+                }
+                generateChapterSummaryUseCase.observeTask(bookUrl, chapterIndex).collect { task ->
+                    if (!isCurrentChapterSummary(
+                            bookUrl,
+                            chapterIndex
+                        ) || task == null
+                    ) return@collect
+                    _uiState.update { state ->
+                        val summary = state.chapterSummary
+                        when (task.status) {
+                            AiArtifact.STATUS_RUNNING -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = true,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
+
+                            AiArtifact.STATUS_SUCCESS -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
+
+                            AiArtifact.STATUS_FAILED -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    errorMessage = task.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                ),
+                            )
+
+                            else -> state
+                        }
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    aiErrorMessage(error),
+                )
+            }
+        }
+    }
+
+    private fun getEffectiveChapterContent(book: Book, chapter: BookChapter): String {
+        val sourceContent = if (book.getTranslationMode()) {
+            TranslationManager.getCachedTranslation(book, chapter)
+                ?: BookHelp.getContent(book, chapter)
+        } else {
+            BookHelp.getContent(book, chapter)
+        } ?: return ""
+        return ContentProcessor.get(book)
+            .getContent(book, chapter, sourceContent, includeTitle = false)
+            .toString()
+    }
+
+    private fun isCurrentChapterSummary(bookUrl: String, chapterIndex: Int): Boolean {
+        val summary = _uiState.value.chapterSummary
+        return host.activeSheet is ReadBookSheet.ChapterSummary &&
+                summary.bookUrl == bookUrl &&
+                summary.chapterIndex == chapterIndex
+    }
+
+    private fun updateChapterSummaryError(
+        bookUrl: String,
+        chapterIndex: Int,
+        message: String,
+    ) {
+        if (!isCurrentChapterSummary(bookUrl, chapterIndex)) return
+        _uiState.update {
+            it.copy(
+                chapterSummary = it.chapterSummary.copy(
+                    isLoading = false,
+                    errorMessage = message,
+                )
+            )
+        }
+    }
+
+    // --- 划词净化 ---
+
+    fun openAiTextClean(
+        text: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+    ) {
+        val book = ReadBook.book ?: return
+        if (text.isBlank()) {
+            host.showToast(context.getString(R.string.ai_text_clean_empty_selection))
+            return
+        }
+        if (chapterIndex != ReadBook.durChapterIndex) {
+            host.showToast(context.getString(R.string.ai_chapter_changed))
+            return
+        }
+        val chapterTitle = host.chapterName
+        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val (contextBefore, contextAfter) = buildSelectionContext(
+            content = visibleContent,
+            selectedText = text,
+            approximatePosition = chapterPosition,
+        )
+        val request = PendingAiTextCleanRequest(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            chapterTitle = chapterTitle,
+            chapterPosition = chapterPosition,
+            originalText = text,
+            contextBefore = contextBefore,
+            contextAfter = contextAfter,
+        )
+        pendingAiTextCleanRequest = request
+        host.closeReadMenu()
+        _uiState.update {
+            it.copy(
+                aiTextClean = AiTextCleanUiState(
+                    bookUrl = request.bookUrl,
+                    chapterIndex = request.chapterIndex,
+                    chapterTitle = request.chapterTitle,
+                    isLoading = true,
+                    originalText = request.originalText,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiTextClean)
+        generateAiTextClean(request)
+    }
+
+    fun retryAiTextClean() {
+        val request = pendingAiTextCleanRequest ?: return
+        _uiState.update {
+            it.copy(
+                aiTextClean = it.aiTextClean.copy(
+                    isLoading = true,
+                    replacementText = "",
+                    streamingText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateAiTextClean(request)
+    }
+
+    private fun generateAiTextClean(request: PendingAiTextCleanRequest) {
+        aiTextCleanJob?.cancel()
+        aiTextCleanJob = scope.launch {
+            try {
+                val taskId = withContext(IO) {
+                    cleanSelectedTextUseCase.start(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        selectedText = request.originalText,
+                        contextBefore = request.contextBefore,
+                        contextAfter = request.contextAfter,
+                    )
+                }
+                cleanSelectedTextUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
+                    if (!isCurrentAiTextClean(request)) return@collect
+                    val clean = _uiState.value.aiTextClean
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = true,
+                                    streamingText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_SUCCESS -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    replacementText = snapshot.output.orEmpty(),
+                                    streamingText = "",
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                )
+                            )
+                        }
+
+                        else -> Unit
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                if (isCurrentAiTextClean(request)) {
+                    _uiState.update {
+                        it.copy(
+                            aiTextClean = it.aiTextClean.copy(
+                                isLoading = false,
+                                errorMessage = aiErrorMessage(error),
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun confirmAiTextClean() {
+        val cleanState = _uiState.value.aiTextClean
+        val book = ReadBook.book ?: return
+        if (cleanState.isLoading || cleanState.isApplying || cleanState.errorMessage != null) return
+        if (book.bookUrl != cleanState.bookUrl ||
+            ReadBook.durChapterIndex != cleanState.chapterIndex
+        ) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_chapter_changed)
+                    )
+                )
+            }
+            return
+        }
+        val pattern = normalizeAiReplacementText(cleanState.originalText)
+        val replacement = normalizeAiReplacementText(cleanState.replacementText)
+        if (pattern.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_empty_selection)
+                    )
+                )
+            }
+            return
+        }
+        if (pattern == replacement) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_no_change)
+                    )
+                )
+            }
+            return
+        }
+
+        _uiState.update {
+            it.copy(aiTextClean = it.aiTextClean.copy(isApplying = true))
+        }
+        scope.launch {
+            try {
+                saveBookContentProcessUseCase.saveReplacement(
+                    bookUrl = cleanState.bookUrl,
+                    chapterIndex = cleanState.chapterIndex,
+                    chapterPosition = pendingAiTextCleanRequest?.chapterPosition ?: 0,
+                    selectedText = pattern,
+                    contextBefore = pendingAiTextCleanRequest?.contextBefore.orEmpty(),
+                    contextAfter = pendingAiTextCleanRequest?.contextAfter.orEmpty(),
+                    replacementText = replacement,
+                ).getOrThrow()
+                host.reloadChapterAfterContentProcessChanged(
+                    bookUrl = cleanState.bookUrl,
+                    chapterIndex = cleanState.chapterIndex,
+                )
+                pendingAiTextCleanRequest = null
+                host.setActiveSheet(null)
+                _uiState.update { it.copy(aiTextClean = AiTextCleanUiState()) }
+                host.showToast(context.getString(R.string.ai_text_clean_rule_created))
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                _uiState.update {
+                    it.copy(
+                        aiTextClean = it.aiTextClean.copy(
+                            isApplying = false,
+                            errorMessage = error.localizedMessage
+                                ?: context.getString(R.string.error),
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    // --- 划词重写 ---
+
+    fun openAiCurrentChapterRewrite() {
+        val book = ReadBook.book ?: return
+        val chapter = ReadBook.curTextChapter?.chapter ?: return
+        scope.launch {
+            val text = withContext(IO) {
+                getEffectiveChapterContent(book, chapter).trim()
+            }
+            if (text.isBlank()) {
+                host.showToast(context.getString(R.string.ai_chapter_content_unavailable))
+                return@launch
+            }
+            openAiTextRewrite(
+                text = text,
+                chapterIndex = ReadBook.durChapterIndex,
+                chapterPosition = 0,
+            )
+        }
+    }
+
+    /**
+     * R2.1:章节标题查询改走 `Host` 的挂起方法(背后是 `BookRepository`,在 IO 上执行),
+     * 因此整个打开流程移进 [scope]——原先是在主线程上同步查库。
+     */
+    suspend fun openAiTextRewrite(
+        text: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+    ) {
+        val book = ReadBook.book ?: return
+        val chapterTitle = host.findChapter(book.bookUrl, chapterIndex)?.title
+            ?: host.chapterName
+        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val (contextBefore, contextAfter) = buildSelectionContext(
+            content = visibleContent,
+            selectedText = text,
+            approximatePosition = chapterPosition,
+        )
+        val presets = loadAiRewritePresets()
+        val selectedPresetId = _uiState.value.aiTextRewrite.selectedPresetId
+            .takeIf { id -> presets.any { it.id == id } }
+            ?: presets.firstOrNull()?.id.orEmpty()
+        val request = PendingAiTextRewriteRequest(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            chapterTitle = chapterTitle,
+            chapterPosition = chapterPosition,
+            originalText = text,
+            sourceContentHash = buildAiRewriteSourceContentHash(text),
+            contextBefore = contextBefore,
+            contextAfter = contextAfter,
+        )
+        pendingAiTextRewriteRequest = request
+        host.closeReadMenu()
+        val currentRewriteState = _uiState.value.aiTextRewrite
+        val isSameRewriteTarget = currentRewriteState.bookUrl == request.bookUrl &&
+                currentRewriteState.chapterIndex == request.chapterIndex &&
+                currentRewriteState.originalText == request.originalText
+        if (!isSameRewriteTarget) {
+            aiTextRewriteJob?.cancel()
+        }
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = if (isSameRewriteTarget) {
+                    it.aiTextRewrite.copy(
+                        chapterTitle = request.chapterTitle,
+                        selectedPresetId = selectedPresetId,
+                        presets = presets.toImmutableList(),
+                    )
+                } else {
+                    AiTextRewriteUiState(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        originalText = request.originalText,
+                        selectedPresetId = selectedPresetId,
+                        presets = presets.toImmutableList(),
+                    )
+                },
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiTextRewrite)
+        loadAiRewriteHistory(request, selectLatest = !isSameRewriteTarget)
+    }
+
+    fun selectAiRewritePreset(presetId: String) {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    selectedPresetId = presetId,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewriteTemporaryInstruction(instruction: String) {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    temporaryInstruction = instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun selectAiRewriteHistory(artifactId: String) {
+        val historyItem = _uiState.value.aiTextRewrite.history
+            .firstOrNull { it.artifactId == artifactId }
+            ?: return
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    rewrittenText = historyItem.text,
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    private fun loadAiRewriteHistory(
+        request: PendingAiTextRewriteRequest,
+        selectLatest: Boolean,
+    ) {
+        scope.launch {
+            val history = withContext(IO) {
+                aiArtifactGateway.getArtifactsByContentHash(
+                    bookUrl = request.bookUrl,
+                    chapterIndex = request.chapterIndex,
+                    taskType = AiTaskType.REWRITE_TEXT,
+                    contentHash = request.sourceContentHash,
+                ).mapNotNull { artifact ->
+                    val text = artifact.output?.takeIf { it.isNotBlank() }
+                        ?: return@mapNotNull null
+                    AiRewriteHistoryUi(
+                        artifactId = artifact.id,
+                        text = text,
+                        timeText = formatAiRewriteHistoryTime(artifact.updatedAt),
+                    )
+                }
+            }
+            val latest = history.firstOrNull()
+            _uiState.update { state ->
+                if (
+                    state.aiTextRewrite.bookUrl != request.bookUrl ||
+                    state.aiTextRewrite.chapterIndex != request.chapterIndex ||
+                    state.aiTextRewrite.originalText != request.originalText
+                ) {
+                    state
+                } else {
+                    state.copy(
+                        aiTextRewrite = state.aiTextRewrite.copy(
+                            history = history.toImmutableList(),
+                            rewrittenText = if (
+                                selectLatest &&
+                                !state.aiTextRewrite.isLoading &&
+                                state.aiTextRewrite.rewrittenText.isBlank() &&
+                                latest != null
+                            ) {
+                                latest.text
+                            } else {
+                                state.aiTextRewrite.rewrittenText
+                            },
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    private fun formatAiRewriteHistoryTime(timestamp: Long): String {
+        val date = Date(timestamp)
+        val dateText = android.text.format.DateFormat.getDateFormat(context).format(date)
+        val timeText = android.text.format.DateFormat.getTimeFormat(context).format(date)
+        return "$dateText $timeText"
+    }
+
+    private fun buildAiRewriteSourceContentHash(text: String): String {
+        return MD5Utils.md5Encode(normalizeAiReplacementText(text))
+    }
+
+    fun generateSelectedAiTextRewrite() {
+        val state = _uiState.value.aiTextRewrite
+        val preset = state.presets.firstOrNull { it.id == state.selectedPresetId }
+        if (preset == null) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_no_preset)
+                    )
+                )
+            }
+            return
+        }
+        val request = pendingAiTextRewriteRequest ?: return
+        generateAiTextRewrite(
+            request = request,
+            preset = preset,
+            temporaryInstruction = state.temporaryInstruction,
+        )
+    }
+
+    fun retryAiTextRewrite() {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    isLoading = false,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateSelectedAiTextRewrite()
+    }
+
+    private fun generateAiTextRewrite(
+        request: PendingAiTextRewriteRequest,
+        preset: AiRewritePresetUi,
+        temporaryInstruction: String,
+    ) {
+        aiTextRewriteJob?.cancel()
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    isLoading = true,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        aiTextRewriteJob = scope.launch {
+            try {
+                val referenceContext = buildAiRewriteReferenceContext(request)
+                val aiRequest = AiTextFactoryUseCase.Request(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        inputText = request.originalText,
+                        taskType = AiTaskType.REWRITE_TEXT,
+                        userInstruction = buildAiRewriteInstruction(
+                            preset.instruction,
+                            temporaryInstruction,
+                        ),
+                        referenceText = referenceContext.text,
+                        skipCache = true,
+                        artifactContentHash = request.sourceContentHash,
+                    )
+                val taskId = withContext(IO) { aiTextFactoryUseCase.start(aiRequest) }
+                aiTextFactoryUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
+                    if (!isCurrentAiTextRewrite(request)) return@collect
+                    val rewrite = _uiState.value.aiTextRewrite
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = true,
+                                    rewrittenText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    referenceCount = referenceContext.count,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_SUCCESS -> {
+                            _uiState.update {
+                                it.copy(
+                                    aiTextRewrite = rewrite.copy(
+                                        isLoading = false,
+                                        rewrittenText = snapshot.output.orEmpty(),
+                                        reasoningText = snapshot.reasoning,
+                                        referenceCount = referenceContext.count,
+                                        errorMessage = null,
+                                    )
+                                )
+                            }
+                            loadAiRewriteHistory(request, selectLatest = false)
+                        }
+
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                )
+                            )
+                        }
+
+                        else -> Unit
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                if (isCurrentAiTextRewrite(request)) {
+                    _uiState.update {
+                        it.copy(
+                            aiTextRewrite = it.aiTextRewrite.copy(
+                                isLoading = false,
+                                errorMessage = aiErrorMessage(error),
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun confirmAiTextRewrite() {
+        val rewriteState = _uiState.value.aiTextRewrite
+        val book = ReadBook.book ?: return
+        if (rewriteState.isLoading || rewriteState.isApplying || rewriteState.errorMessage != null) return
+        if (book.bookUrl != rewriteState.bookUrl ||
+            ReadBook.durChapterIndex != rewriteState.chapterIndex
+        ) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_chapter_changed)
+                    )
+                )
+            }
+            return
+        }
+        val pattern = normalizeAiReplacementText(rewriteState.originalText)
+        val replacement = normalizeAiReplacementText(rewriteState.rewrittenText)
+        if (pattern.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_empty_selection)
+                    )
+                )
+            }
+            return
+        }
+        if (replacement.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_empty_result)
+                    )
+                )
+            }
+            return
+        }
+        if (pattern == replacement) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_no_change)
+                    )
+                )
+            }
+            return
+        }
+
+        _uiState.update {
+            it.copy(aiTextRewrite = it.aiTextRewrite.copy(isApplying = true))
+        }
+        scope.launch {
+            try {
+                saveBookContentProcessUseCase.saveReplacement(
+                    bookUrl = rewriteState.bookUrl,
+                    chapterIndex = rewriteState.chapterIndex,
+                    chapterPosition = pendingAiTextRewriteRequest?.chapterPosition ?: 0,
+                    selectedText = pattern,
+                    contextBefore = pendingAiTextRewriteRequest?.contextBefore.orEmpty(),
+                    contextAfter = pendingAiTextRewriteRequest?.contextAfter.orEmpty(),
+                    replacementText = replacement,
+                    kind = BookContentProcess.KIND_AI_REWRITE,
+                ).getOrThrow()
+                host.reloadChapterAfterContentProcessChanged(
+                    bookUrl = rewriteState.bookUrl,
+                    chapterIndex = rewriteState.chapterIndex,
+                )
+                pendingAiTextRewriteRequest = null
+                host.setActiveSheet(null)
+                _uiState.update { it.copy(aiTextRewrite = AiTextRewriteUiState()) }
+                host.showToast(context.getString(R.string.ai_text_rewrite_saved))
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                _uiState.update {
+                    it.copy(
+                        aiTextRewrite = it.aiTextRewrite.copy(
+                            isApplying = false,
+                            errorMessage = error.localizedMessage
+                                ?: context.getString(R.string.error),
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    // --- 重写预设配置 ---
+
+    fun openAiRewritePresetConfig() {
+        val presets = loadAiRewritePresets()
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = false,
+                    presets = presets.toImmutableList(),
+                    errorMessage = null,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiRewritePresetConfig)
+    }
+
+    fun closeAiRewritePresetConfig() {
+        val nextSheet = if (pendingAiTextRewriteRequest != null) {
+            ReadBookSheet.AiTextRewrite
+        } else {
+            null
+        }
+        host.setActiveSheet(nextSheet)
+        _uiState.update { it.copy(aiRewritePresetConfig = AiRewritePresetConfigUiState()) }
+    }
+
+    fun startAddAiRewritePreset() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = true,
+                    editingPresetId = null,
+                    editingName = "",
+                    editingInstruction = "",
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun startEditAiRewritePreset(preset: AiRewritePresetUi) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = true,
+                    editingPresetId = preset.id,
+                    editingName = preset.name,
+                    editingInstruction = preset.instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewritePresetName(name: String) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editingName = name,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewritePresetInstruction(instruction: String) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editingInstruction = instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun saveAiRewritePreset() {
+        val config = _uiState.value.aiRewritePresetConfig
+        val name = config.editingName.trim()
+        val instruction = config.editingInstruction.trim()
+        if (name.isBlank() || instruction.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_preset_empty)
+                    )
+                )
+            }
+            return
+        }
+        val editingId = config.editingPresetId
+        val savedPresets = if (editingId == null) {
+            config.presets + AiRewritePresetUi(
+                id = UUID.randomUUID().toString(),
+                name = name,
+                instruction = instruction,
+            )
+        } else {
+            config.presets.map { preset ->
+                if (preset.id == editingId) {
+                    preset.copy(name = name, instruction = instruction)
+                } else {
+                    preset
+                }
+            }
+        }
+        saveAiRewritePresets(savedPresets)
+        syncAiRewritePresets(savedPresets)
+        clearAiRewritePresetDraft()
+    }
+
+    fun clearAiRewritePresetDraft() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = false,
+                    editingPresetId = null,
+                    editingName = "",
+                    editingInstruction = "",
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun requestDeleteAiRewritePreset(preset: AiRewritePresetUi) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = preset)
+            )
+        }
+    }
+
+    fun dismissDeleteAiRewritePreset() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = null)
+            )
+        }
+    }
+
+    fun deleteAiRewritePreset() {
+        val deletePreset = _uiState.value.aiRewritePresetConfig.deletePreset ?: return
+        val savedPresets = _uiState.value.aiRewritePresetConfig.presets
+            .filterNot { it.id == deletePreset.id }
+        aiPromptPresetGateway.deletePresetSync(deletePreset.id)
+        syncAiRewritePresets(savedPresets)
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = null)
+            )
+        }
+    }
+
+    private fun syncAiRewritePresets(presets: List<AiRewritePresetUi>) {
+        val selected = _uiState.value.aiTextRewrite.selectedPresetId
+            .takeIf { id -> presets.any { it.id == id } }
+            ?: presets.firstOrNull()?.id.orEmpty()
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    presets = presets.toImmutableList(),
+                    selectedPresetId = selected,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                ),
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    presets = presets.toImmutableList(),
+                ),
+            )
+        }
+    }
+
+    private fun loadAiRewritePresets(): List<AiRewritePresetUi> {
+        if (aiPromptPresetGateway.countByTaskTypeSync(AiTaskType.REWRITE_TEXT) == 0) {
+            aiPromptPresetGateway.savePresetsSync(
+                defaultAiRewritePresets().mapIndexed { index, preset ->
+                    preset.toAiPromptPreset(index)
+                }
+            )
+        }
+        return aiPromptPresetGateway.getEnabledByTaskType(AiTaskType.REWRITE_TEXT)
+            .map { it.toAiRewritePresetUi() }
+    }
+
+    private fun saveAiRewritePresets(presets: List<AiRewritePresetUi>) {
+        aiPromptPresetGateway.savePresetsSync(
+            presets.mapIndexed { index, preset ->
+                preset.toAiPromptPreset(index)
+            }
+        )
+    }
+
+    private fun defaultAiRewritePresets(): List<AiRewritePresetUi> {
+        return listOf(
+            AiRewritePresetUi(
+                id = "default_polish",
+                name = context.getString(R.string.ai_rewrite_preset_polish_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_polish_instruction),
+            ),
+            AiRewritePresetUi(
+                id = "default_concise",
+                name = context.getString(R.string.ai_rewrite_preset_concise_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_concise_instruction),
+            ),
+            AiRewritePresetUi(
+                id = "default_dialogue",
+                name = context.getString(R.string.ai_rewrite_preset_dialogue_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_dialogue_instruction),
+            ),
+        )
+    }
+
+    private fun AiRewritePresetUi.toAiPromptPreset(sortNumber: Int): AiPromptPreset {
+        val now = System.currentTimeMillis()
+        return AiPromptPreset(
+            id = id,
+            taskType = AiTaskType.REWRITE_TEXT,
+            name = name,
+            instruction = instruction,
+            builtIn = id.startsWith("default_"),
+            sortNumber = sortNumber,
+            createdAt = now,
+            updatedAt = now,
+        )
+    }
+
+    private fun AiPromptPreset.toAiRewritePresetUi(): AiRewritePresetUi {
+        return AiRewritePresetUi(
+            id = id,
+            name = name,
+            instruction = instruction,
+        )
+    }
+
+    private fun buildAiRewriteInstruction(
+        presetInstruction: String,
+        temporaryInstruction: String,
+    ): String {
+        val temporary = temporaryInstruction.trim()
+        if (temporary.isBlank()) return presetInstruction
+        return buildString {
+            append(presetInstruction)
+            append("\n\nTemporary instruction for this rewrite only:\n")
+            append(temporary)
+        }
+    }
+
+    private suspend fun buildAiRewriteReferenceContext(
+        request: PendingAiTextRewriteRequest,
+    ): AiRewriteReferenceContext = withContext(IO) {
+        val book = ReadBook.book
+            ?.takeIf { it.bookUrl == request.bookUrl }
+            ?: return@withContext AiRewriteReferenceContext()
+        val terms = extractAiRewriteReferenceTerms(request.originalText)
+        if (terms.isEmpty()) return@withContext AiRewriteReferenceContext()
+
+        val chapters = host.listChapters(request.bookUrl)
+            .asSequence()
+            .filter { it.index != request.chapterIndex }
+            .sortedWith(
+                compareBy<BookChapter> { kotlin.math.abs(it.index - request.chapterIndex) }
+                    .thenBy { it.index }
+            )
+            .take(AI_REWRITE_REFERENCE_SCAN_CHAPTERS)
+            .toList()
+
+        val excerpts = mutableListOf<String>()
+        for (chapter in chapters) {
+            coroutineContext.ensureActive()
+            val content = BookHelp.getContent(book, chapter) ?: continue
+            val term = terms.firstOrNull { term ->
+                chapter.title.contains(term) || content.contains(term)
+            } ?: continue
+            val excerpt = extractAiRewriteReferenceExcerpt(content, term)
+            if (excerpt.isBlank()) continue
+            excerpts += buildString {
+                append("Chapter ")
+                append(chapter.index + 1)
+                if (chapter.title.isNotBlank()) {
+                    append(": ")
+                    append(chapter.title)
+                }
+                append("\nKeyword: ")
+                append(term)
+                append("\n")
+                append(excerpt)
+            }
+            if (excerpts.size >= AI_REWRITE_REFERENCE_MAX_EXCERPTS) break
+        }
+
+        if (excerpts.isEmpty()) {
+            AiRewriteReferenceContext()
+        } else {
+            AiRewriteReferenceContext(
+                text = excerpts.joinToString("\n\n---\n\n"),
+                count = excerpts.size,
+            )
+        }
+    }
+
+    private fun extractAiRewriteReferenceTerms(text: String): List<String> {
+        val stopWords = setOf(
+            "自己", "他们", "她们", "你们", "我们", "这个", "那个", "什么", "只是", "没有",
+            "不是", "已经", "知道", "起来", "一下", "心里", "眼前", "声音", "时候", "突然",
+            "微微", "终于", "如果", "因为", "所以", "但是", "然后", "似乎", "仿佛", "开始",
+        )
+        val counts = linkedMapOf<String, Int>()
+        fun addTerm(term: String) {
+            val normalized = term.trim()
+            if (normalized.length < 2 || normalized in stopWords) return
+            counts[normalized] = (counts[normalized] ?: 0) + 1
+        }
+
+        Regex("""([\u4e00-\u9fa5]{2,4})(?:说|问|道|喊|叫|笑|答|叹|想|看|望|皱眉|点头|摇头)""")
+            .findAll(text)
+            .forEach { match -> addTerm(match.groupValues[1]) }
+        Regex("""\b[A-Z][A-Za-z]{2,}\b""")
+            .findAll(text)
+            .forEach { match -> addTerm(match.value) }
+
+        return counts.entries
+            .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
+            .map { it.key }
+            .take(6)
+    }
+
+    private fun extractAiRewriteReferenceExcerpt(content: String, term: String): String {
+        val paragraphs = content.lines()
+            .map { it.trim() }
+            .filter { it.isNotBlank() }
+        val paragraphIndex = paragraphs.indexOfFirst { it.contains(term) }
+        if (paragraphIndex >= 0) {
+            val start = (paragraphIndex - 1).coerceAtLeast(0)
+            val end = (paragraphIndex + 2).coerceAtMost(paragraphs.size)
+            return trimAiRewriteReferenceExcerpt(
+                paragraphs.subList(start, end).joinToString("\n"),
+                term,
+            )
+        }
+        return trimAiRewriteReferenceExcerpt(content, term)
+    }
+
+    private fun trimAiRewriteReferenceExcerpt(text: String, term: String): String {
+        if (text.length <= AI_REWRITE_REFERENCE_EXCERPT_CHARS) return text.trim()
+        val index = text.indexOf(term).takeIf { it >= 0 } ?: 0
+        val start = (index - AI_REWRITE_REFERENCE_EXCERPT_CHARS / 2).coerceAtLeast(0)
+        val end = (start + AI_REWRITE_REFERENCE_EXCERPT_CHARS).coerceAtMost(text.length)
+        return text.substring(start, end).trim()
+    }
+
+    private fun isCurrentAiTextClean(request: PendingAiTextCleanRequest): Boolean {
+        val clean = _uiState.value.aiTextClean
+        return host.activeSheet is ReadBookSheet.AiTextClean &&
+                clean.bookUrl == request.bookUrl &&
+                clean.chapterIndex == request.chapterIndex &&
+                clean.originalText == request.originalText
+    }
+
+    private fun isCurrentAiTextRewrite(request: PendingAiTextRewriteRequest): Boolean {
+        val rewrite = _uiState.value.aiTextRewrite
+        return rewrite.bookUrl == request.bookUrl &&
+                rewrite.chapterIndex == request.chapterIndex &&
+                rewrite.originalText == request.originalText
+    }
+
+    private fun buildSelectionContext(
+        content: String,
+        selectedText: String,
+        approximatePosition: Int,
+    ): Pair<String, String> {
+        if (content.isBlank()) return "" to ""
+        val start = findClosestOccurrence(content, selectedText, approximatePosition)
+        if (start < 0) return "" to ""
+        val end = start + selectedText.length
+        return content.substring((start - AI_TEXT_CONTEXT_CHARS).coerceAtLeast(0), start) to
+                content.substring(end, (end + AI_TEXT_CONTEXT_CHARS).coerceAtMost(content.length))
+    }
+
+    private fun findClosestOccurrence(
+        content: String,
+        selectedText: String,
+        approximatePosition: Int,
+    ): Int {
+        var match = content.indexOf(selectedText)
+        if (match < 0) return -1
+        var closest = match
+        var closestDistance = kotlin.math.abs(match - approximatePosition)
+        while (match >= 0) {
+            val distance = kotlin.math.abs(match - approximatePosition)
+            if (distance < closestDistance) {
+                closest = match
+                closestDistance = distance
+            }
+            match = content.indexOf(selectedText, match + 1)
+        }
+        return closest
+    }
+
+    private fun normalizeAiReplacementText(text: String): String {
+        val indent = ReadBookConfig.paragraphIndent
+        return text.lines()
+            .joinToString("\n") { line -> line.removePrefix(indent).trim() }
+            .trim()
+    }
+
+    private fun aiErrorMessage(error: Throwable): String {
+        return when {
+            error.message?.contains("No AI model configured", ignoreCase = true) == true ->
+                context.getString(R.string.ai_model_not_configured)
+
+            else -> error.localizedMessage ?: context.getString(R.string.error)
+        }
+    }
+}
+
+private const val AI_TEXT_CONTEXT_CHARS = 1000
+private const val AI_REWRITE_REFERENCE_SCAN_CHAPTERS = 80
+private const val AI_REWRITE_REFERENCE_MAX_EXCERPTS = 6
+private const val AI_REWRITE_REFERENCE_EXCERPT_CHARS = 600
+
+private data class PendingAiTextCleanRequest(
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapterPosition: Int,
+    val originalText: String,
+    val contextBefore: String,
+    val contextAfter: String,
+)
+
+private data class PendingAiTextRewriteRequest(
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapterPosition: Int,
+    val originalText: String,
+    val sourceContentHash: String,
+    val contextBefore: String,
+    val contextAfter: String,
+)
+
+private data class AiRewriteReferenceContext(
+    val text: String = "",
+    val count: Int = 0,
+)

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

@@ -0,0 +1,438 @@
+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 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) }
+        }
+    }
+}

+ 34 - 137
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -11,18 +11,15 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
-import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
-import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
 import kotlinx.collections.immutable.persistentListOf
@@ -92,6 +89,11 @@ data class ReadBookStyleConfig(
     val pageAnimEInk: Int = 4,
     // Layout
     val shareLayout: Boolean = false,
+    // 排版自带的阅读菜单配色,作为 DataStore 里 readMenu*Color 为 0(未自定义)时的回退
+    val menuBgColorDay: Int = 0,
+    val menuBgColorNight: Int = 0,
+    val menuAccentColorDay: Int = 0,
+    val menuAccentColorNight: Int = 0,
     // Config list for style selector
     val configCount: Int = 1,
     val styleItems: ImmutableList<ReadStyleItem> = persistentListOf(),
@@ -103,6 +105,8 @@ data class ReadBookStyleConfig(
 
 @Stable
 data class ReadSheetConfigUiState(
+    /** 标题字号 <8 时按「正文字号 + 偏移」解释,故正文字号也要进弹层快照。 */
+    val textSize: Int = 20,
     val letterSpacing: Float = 0f,
     val lineSpacing: Int = 0,
     val paragraphSpacing: Int = 0,
@@ -158,7 +162,25 @@ data class ReadSheetConfigUiState(
     val headerFontSize: Int = 12,
     val footerFontSize: Int = 12,
     val applyHeaderStyle: Boolean = true,
+    /** -1 表示"跟随主题分割线",0 表示"跟随正文颜色"。 */
     val tipDividerColor: Int = 0,
+    val headerMode: Int = 0,
+    val footerMode: Int = 0,
+    val showHeaderLine: Boolean = false,
+    val showFooterLine: Boolean = false,
+    val tipHeaderLeft: Int = 0,
+    val tipHeaderMiddle: Int = 0,
+    val tipHeaderRight: Int = 0,
+    val tipFooterLeft: Int = 0,
+    val tipFooterMiddle: Int = 0,
+    val tipFooterRight: Int = 0,
+    val customTipHeaderLeft: String = "",
+    val customTipHeaderMiddle: String = "",
+    val customTipHeaderRight: String = "",
+    val customTipFooterLeft: String = "",
+    val customTipFooterMiddle: String = "",
+    val customTipFooterRight: String = "",
+    /** 0 表示"跟随正文颜色",展示时回退到 [textColorDay] / [textColorNight]。 */
     val tipHeaderColor: Int = 0,
     val tipHeaderColorNight: Int = 0,
     val tipFooterColor: Int = 0,
@@ -168,77 +190,6 @@ data class ReadSheetConfigUiState(
     val configNames: ImmutableList<String> = persistentListOf(),
 )
 
-@Stable
-data class ChapterSummaryUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val summary: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiTextCleanUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val isApplying: Boolean = false,
-    val originalText: String = "",
-    val replacementText: String = "",
-    val streamingText: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiRewritePresetUi(
-    val id: String,
-    val name: String,
-    val instruction: String,
-)
-
-@Stable
-data class AiRewriteHistoryUi(
-    val artifactId: String,
-    val text: String,
-    val timeText: String,
-)
-
-@Stable
-data class AiTextRewriteUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val isApplying: Boolean = false,
-    val originalText: String = "",
-    val rewrittenText: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val selectedPresetId: String = "",
-    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
-    val temporaryInstruction: String = "",
-    val history: ImmutableList<AiRewriteHistoryUi> = persistentListOf(),
-    val referenceCount: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiRewritePresetConfigUiState(
-    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
-    val editing: Boolean = false,
-    val editingPresetId: String? = null,
-    val editingName: String = "",
-    val editingInstruction: String = "",
-    val deletePreset: AiRewritePresetUi? = null,
-    val errorMessage: String? = null,
-)
-
 @Stable
 data class ReadBookUiState(
     val book: Book? = null,
@@ -300,17 +251,7 @@ data class ReadBookUiState(
     val sameTitleRemoved: Boolean = false,
     val isReadingProgressSyncConfigured: Boolean = false,
     // Content edit
-    val contentEditLoading: Boolean = false,
-    val contentEditText: String = "",
-    val contentEditTitle: String = "",
-    val contentEditCursorOffset: Int = 0,
-    val contentEditIsLocalTxt: Boolean = false,
-    val contentEditSaveToSource: Boolean = false,
-    val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
-    val selectedTtsEngine: String? = null,
-    val speakEngineName: String = "",
-    val editingHttpTts: HttpTTS? = null,
-    val httpTtsImportState: BaseImportUiState<HttpTTS> = BaseImportUiState.Idle,
+    // 正文编辑域状态见 ContentEditUiState —— 由 ReadContentEditDelegate 独立持有
     val preDownloadNum: Int = 10,
     val preSynthesisConcurrency: Int = 3,
     val audioCacheCleanTime: Int = 10,
@@ -338,12 +279,7 @@ data class ReadBookUiState(
     val sheetConfig: ReadSheetConfigUiState = ReadSheetConfigUiState(),
     // Menu config (from ReadBookConfig via repository)
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
-    val highlightRuleConfig: HighlightRuleConfigUiState = HighlightRuleConfigUiState(),
-    val contentProcessConfig: ContentProcessConfigUiState = ContentProcessConfigUiState(),
-    val chapterSummary: ChapterSummaryUiState = ChapterSummaryUiState(),
-    val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
-    val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
-    val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+    // AI 域状态见 ReadAiUiState —— 由 ReadAiDelegate 独立持有
     val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
 ) {
     val menuVisible: Boolean
@@ -364,15 +300,6 @@ data class EyeProtectionUiState(
         get() = enabled || autoNight
 }
 
-@Stable
-data class HighlightRuleConfigUiState(
-    val rules: ImmutableList<HighlightRule> = persistentListOf(),
-    val editingRule: HighlightRule? = null,
-    val showNewRule: Boolean = false,
-    val deleteRule: HighlightRule? = null,
-    val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
-)
-
 @Stable
 data class ContentProcessConfigUiState(
     val isLoading: Boolean = false,
@@ -436,13 +363,6 @@ data class ReadMenuConfig(
     val titleBarCompact: Boolean = false,
 )
 
-@Immutable
-data class ReadBookTtsEngineItem(
-    val title: String,
-    val value: String?,
-    val loginUrl: String? = null,
-)
-
 @Immutable
 data class ReadBookButtonConfigItem(
     val id: String,
@@ -648,9 +568,6 @@ sealed interface ReadBookIntent {
     // Bookshelf
     data object RemoveFromBookshelf : ReadBookIntent
 
-    // Config update (triggers ReadView upBg/upStyle etc.)
-    data class OnConfigUpdated(val actions: Set<ConfigUpdateAction>) : ReadBookIntent
-
     // Typed config mutation — single entry point for all ReadBookConfig changes
     data class UpdateConfig(val update: ConfigUpdate) : ReadBookIntent
 
@@ -756,33 +673,14 @@ sealed interface ReadBookIntent {
 
     // Read aloud config (needs Activity for DialogFragment)
     data object ShowReadAloudConfig : ReadBookIntent
-    data object SelectSpeakEngine : ReadBookIntent
     data object OpenPreDownloadNumPicker : ReadBookIntent
     data object OpenPreSynthesisConcurrencyPicker : ReadBookIntent
     data object OpenParagraphIntervalPicker : ReadBookIntent
     data object OpenCacheCleanTimePicker : ReadBookIntent
-    data class ApplySpeakEngine(val value: String?) : ReadBookIntent
     data class ApplyPreDownloadNum(val value: Int) : ReadBookIntent
     data class ApplyPreSynthesisConcurrency(val value: Int) : ReadBookIntent
     data class ApplyAudioCacheCleanTime(val value: Int) : ReadBookIntent
     data class ApplyParagraphInterval(val value: Int) : ReadBookIntent
-    data class EditHttpTts(val engineId: Long? = null) : ReadBookIntent
-    data class DeleteHttpTts(val engineId: Long) : ReadBookIntent
-    data class SaveHttpTts(val httpTTS: HttpTTS) : ReadBookIntent
-    data class ApplySpeakEnginePerBook(val value: String?) : ReadBookIntent
-    data class OpenHttpTtsLogin(val engineId: Long) : ReadBookIntent
-    data class ImportHttpTtsJson(val json: String) : ReadBookIntent
-    data class ImportHttpTtsSource(val text: String) : ReadBookIntent
-    data object ImportHttpTtsFile : ReadBookIntent
-    data class ImportHttpTtsFileSelected(val uri: Uri) : ReadBookIntent
-    data object CancelHttpTtsImport : ReadBookIntent
-    data class ToggleHttpTtsImportSelection(val index: Int) : ReadBookIntent
-    data class ToggleHttpTtsImportAll(val isSelected: Boolean) : ReadBookIntent
-    data class UpdateHttpTtsImportItem(val index: Int, val httpTTS: HttpTTS) : ReadBookIntent
-    data object SaveImportedHttpTts : ReadBookIntent
-    data object ExportAllHttpTts : ReadBookIntent
-    data object ExportAllHttpTtsAsUrl : ReadBookIntent
-    data class ExportHttpTtsToFile(val uri: Uri) : ReadBookIntent
     data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
@@ -862,9 +760,8 @@ sealed interface ReadBookEffect {
     data object UpAloudState : ReadBookEffect
     data object UpSeekBar : ReadBookEffect
     data object UpMenuView : ReadBookEffect
-    data object PageChanged : ReadBookEffect
-    data object ContentLoadFinish : ReadBookEffect
-    data class LayoutPageCompleted(val index: Int, val page: TextPage) : ReadBookEffect
+    // R2.3:PageChanged / ContentLoadFinish / LayoutPageCompleted 已内联进
+    // ReadBookController 的渲染回调——它们只在 controller 内部自产自销,不是 VM 的对外协议。
     data object RefreshBookContent : ReadBookEffect
 
     // Menu / UI actions
@@ -954,9 +851,6 @@ sealed interface ReadBookEffect {
     data class OpenMenuCustomIconPicker(val id: String) : ReadBookEffect
     data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookEffect
     data object OpenSystemTtsSettings : ReadBookEffect
-    data object OpenHttpTtsImportPicker : ReadBookEffect
-    data object OpenHttpTtsExportPicker : ReadBookEffect
-    data class OpenHttpTtsLogin(val engineId: Long) : ReadBookEffect
     data object OpenTtsEnginesAndVoices : ReadBookEffect
     data object OpenTtsCache : ReadBookEffect
     data class OpenBookVoiceCasting(val bookUrl: String) : ReadBookEffect
@@ -1014,8 +908,6 @@ sealed interface ReadBookSheet {
     data object BgTextConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
     data object ReadAloudPlayer : ReadBookSheet
-    data object SpeakEngineConfig : ReadBookSheet
-    data class HttpTtsEdit(val engineId: Long? = null) : ReadBookSheet
     data object PreDownloadConfig : ReadBookSheet
     data object PreSynthesisConcurrencyConfig : ReadBookSheet
     data object AudioCacheCleanConfig : ReadBookSheet
@@ -1344,6 +1236,8 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout)
     }
     data class UnderlineColor(val color: Int) : ConfigUpdate {
+        // 对齐兄弟项 Underline/DottedLine:改颜色要重绘已排好的 TextPage,
+        // 仅 UpdateStyle(readView.upStyle())不会重画下划线。
         override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask)
     }
 
@@ -1418,6 +1312,9 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateBackgroundAlpha)
     }
     data class StatusIconDark(val value: Boolean) : ConfigUpdate {
+        // 唯一生效路径是 UpdateSystemUi → upSystemUiVisibility() →
+        // setLightStatusBar(curStatusIconDark())(ReadBookController:318)。
+        // 原本的 ReloadContent 只会重新加载正文,与状态栏图标无关。
         override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
     data class StyleName(val value: String) : ConfigUpdate {

+ 192 - 33
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -43,8 +43,15 @@ import io.legado.app.receiver.TimeBatteryReceiver
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
+import io.legado.app.ui.book.read.page.ReaderEvent
+import io.legado.app.ui.book.read.page.ReaderEventListener
+import io.legado.app.ui.book.read.page.ReaderPageSource
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextPage
+import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.login.SourceLoginJsExtensions
@@ -89,10 +96,16 @@ class ReadBookController(
 ) : ReadBookRouteHost,
     ReadBookInputHandler,
     ReadView.CallBack,
-    ContentTextView.CallBack {
+    ContentTextView.CallBack,
+    ReadBook.ReaderRenderCallback {
 
     var refs: ReadBookViewRefs? = null
 
+    internal val layoutController = ReaderLayoutCoordinator(
+        updateLayoutSize = ChapterProvider::upViewSize,
+        relayoutContent = ReadBook::relayoutContent,
+    )
+
     // Fallback handler for effects not yet migrated to controller
     var onUnhandledEffect: (ReadBookEffect) -> Unit = {}
     var onClose: (() -> Unit)? = null
@@ -160,6 +173,7 @@ class ReadBookController(
     }
 
     fun clearTts() {
+        ReadBook.unregisterRender(this)
         tts?.clearTts()
         tts = null
         dismissTextActionMenu()
@@ -203,6 +217,8 @@ class ReadBookController(
         newRefs.readView.upTime()
         newRefs.readView.upBattery(activity.sysBattery)
         refreshActionMenuItems()
+        // 视图就绪后接管渲染回调;registerRender 会在已有内容时立即同步一次,避免首帧漏渲染
+        ReadBook.registerRender(this)
     }
 
     fun onMenuVisibilityChanged(visible: Boolean) {
@@ -311,14 +327,16 @@ class ReadBookController(
         }
     }
 
-    // ── ReadView.CallBack ─────────────────────────────────────────────
+    // ── ReadView 协作面 ────────────────────────────────────────────────
+    // override 的四项是 ReadView.CallBack(瞬时 UI 副作用 + 首帧门闩);
+    // private 的几项是 ReaderEvent 的落点,只由下面的 onEvent 调用。
 
     // initData 还没跑完时, 只要 ReadBook 里已有本书可用的章节就让 ReadView 直接画正文,
     // 否则首帧必然是 "加载数据中" 占位页
     override val isInitFinish: Boolean
         get() = viewModel.uiState.value.isInitFinish || viewModel.isCachedChapterUsable()
 
-    override fun showActionMenu() {
+    private fun showActionMenu() {
         val state = viewModel.uiState.value
         when {
             BaseReadAloudService.isRun -> viewModel.onIntent(
@@ -366,19 +384,19 @@ class ReadBookController(
         }
     }
 
-    override fun autoPageStop() {
+    private fun autoPageStop() {
         viewModel.onIntent(ReadBookIntent.StopAutoPage)
     }
 
-    override fun openChapterList() {
+    private fun openChapterList() {
         viewModel.onIntent(ReadBookIntent.OpenChapterList)
     }
 
-    override fun openContentEdit() {
+    private fun openContentEdit() {
         viewModel.onIntent(ReadBookIntent.OpenContentEdit)
     }
 
-    override fun addBookmark() {
+    private fun addBookmark() {
         val book = ReadBook.book
         val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex)
         if (book != null && page != null) {
@@ -392,11 +410,11 @@ class ReadBookController(
         }
     }
 
-    override fun changeReplaceRuleState() {
+    private fun changeReplaceRuleState() {
         viewModel.onIntent(ReadBookIntent.MenuEnableReplace)
     }
 
-    override fun openSearchActivity(searchWord: String?) {
+    private fun openSearchActivity(searchWord: String?) {
         viewModel.onIntent(ReadBookIntent.OpenSearch(searchWord))
     }
 
@@ -405,13 +423,95 @@ class ReadBookController(
         upSystemUiVisibility(isInMultiWindowModeCompat, !state.menuVisible)
     }
 
-    override fun sureNewProgress(progress: BookProgress) {
+    private fun sureNewProgress(progress: BookProgress) {
         viewModel.onIntent(ReadBookIntent.SureNewProgress(progress))
     }
 
+    // ── ReaderPageSource(Track D·D2:喂给渲染层的页数据 + 接下页位置命令)────
+
+    override val durChapterIndex: Int get() = ReadBook.durChapterIndex
+
+    override val durPageIndex: Int get() = ReadBook.durPageIndex
+
+    override val simulatedChapterSize: Int get() = ReadBook.simulatedChapterSize
+
+    override val pageAnim: Int get() = ReadBook.pageAnim()
+
+    override val msg: String? get() = ReadBook.msg
+
+    override fun textChapter(chapterOnDur: Int): TextChapter? =
+        ReadBook.textChapter(chapterOnDur)
+
+    override fun setPageIndex(index: Int) = ReadBook.setPageIndex(index)
+
+    // upContentInPlace = false:取页器在命令返回后自己决定要不要 upContent,别刷两遍
+    override fun moveToNextChapter(upContent: Boolean) {
+        ReadBook.moveToNextChapter(upContent, upContentInPlace = false)
+    }
+
+    override fun moveToPrevChapter(upContent: Boolean) {
+        ReadBook.moveToPrevChapter(upContent, upContentInPlace = false)
+    }
+
+    // ── ReaderEventListener(Track D·D1:ReadView 的出站业务意图)─────────
+
+    override fun onEvent(event: ReaderEvent) {
+        when (event) {
+            ReaderEvent.ShowActionMenu -> showActionMenu()
+            ReaderEvent.AutoPageStop -> autoPageStop()
+            ReaderEvent.OpenChapterList -> openChapterList()
+            ReaderEvent.OpenContentEdit -> openContentEdit()
+            ReaderEvent.OpenSearch -> openSearchActivity(null)
+            ReaderEvent.AddBookmark -> addBookmark()
+            ReaderEvent.ChangeReplaceRuleState -> changeReplaceRuleState()
+            ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
+            ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
+            ReaderEvent.ReadAloudPrevParagraph ->
+                viewModel.onIntent(ReadBookIntent.ReadAloudPrevParagraph)
+
+            ReaderEvent.ReadAloudNextParagraph ->
+                viewModel.onIntent(ReadBookIntent.ReadAloudNextParagraph)
+
+            // 只做暂停/续读,与菜单栏的 toggleReadAloud()(含"朗读未启动则启动"编排)不同,
+            // 保持点击区原语义:朗读没开着时这个手势是空操作。
+            ReaderEvent.ToggleReadAloudPause -> if (BaseReadAloudService.isPlay()) {
+                ReadAloud.pause(activity)
+            } else {
+                ReadAloud.resume(activity)
+            }
+
+            ReaderEvent.SyncProgress -> ReadBook.syncProgress(
+                { progress -> sureNewProgress(progress) },
+                { activity.longToastOnUi(activity.getString(R.string.upload_book_success)) },
+                { activity.longToastOnUi(activity.getString(R.string.sync_book_progress_success)) }
+            )
+        }
+    }
+
+    /**
+     * 从选择位置开始朗读:先把阅读位置推进到所选页,再按行/列换算章内位置起读。
+     * 原先在 ReadView 内(Track D·D1 迁出——ReadView 不下达业务命令)。
+     */
+    private suspend fun aloudStartSelect(selectStartPos: TextPos) {
+        val readView = refs?.readView ?: return
+        var pagePos = selectStartPos.relativePagePos
+        while (pagePos > 0) {
+            if (!ReadBook.moveToNextPage()) {
+                ReadBook.moveToNextChapterAwait(false)
+            }
+            pagePos--
+        }
+        val startPos = readView.posByLineColumn(
+            selectStartPos.lineIndex,
+            selectStartPos.columnIndex
+        )
+        ReadBook.readAloud(startPos = startPos)
+    }
+
     // ── ContentTextView.CallBack ──────────────────────────────────────
 
     override val headerHeight: Int get() = refs?.readView?.curPage?.headerHeight ?: 0
+    override val readerLayoutController: ReaderLayoutController get() = layoutController
     override val imgBgPaddingStart: Int get() = refs?.readView?.curPage?.imgBgPaddingStart ?: 0
     override val pageFactory: TextPageFactory
         get() = refs?.readView?.pageFactory ?: error("ReadView not ready")
@@ -824,6 +924,77 @@ class ReadBookController(
         onMenuActionFinally()
     }
 
+    // ── ReadBook.ReaderRenderCallback(渲染子集,Track B2 从 ViewModel 下沉)──
+    //
+    // ReadBook 在其 IO 协程内调用这些渲染回调(见 contentLoadFinish 的 Coroutine.async
+    // 默认跑在 Dispatchers.IO),而回调最终会触碰 refs.readView。旧路径靠 VM 的
+    // SharedFlow → 生命周期收集器把渲染派发切回主线程;这里用 [postRender] 保留同样的
+    // 异步-切主线程语义,复用既有的 handleEffect 渲染分支,零渲染逻辑漂移。
+
+    private fun postRender(effect: ReadBookEffect) {
+        handler.post { handleEffect(effect) }
+    }
+
+    override fun upContent(
+        relativePosition: Int,
+        resetPageOffset: Boolean,
+        success: (() -> Unit)?
+    ) {
+        postRender(ReadBookEffect.UpContent(relativePosition, resetPageOffset, success))
+    }
+
+    override suspend fun upContentAwait(
+        relativePosition: Int,
+        resetPageOffset: Boolean,
+        success: (() -> Unit)?
+    ) {
+        if (layoutController.awaitViewport() == null) {
+            AppLog.putDebug("upContentAwait 等待 ReaderViewport 超时,继续旧内容更新路径")
+        }
+        withContext(Main.immediate) {
+            handleEffect(ReadBookEffect.UpContent(relativePosition, resetPageOffset, success))
+        }
+    }
+
+    // R2.3:pageChanged / contentLoadFinish / onLayoutPageCompleted 的 Effect 只有本类
+    // 自产自销(postRender → 本类 handleEffect),从不经过 ViewModel 的 _effects。
+    // 它们不属于 VM 的对外协议,故内联到渲染方法里,三个 Effect 类型随之从
+    // ReadBookEffect 删除。仍在 postRender 上的 UpContent/UpPageAnim/CancelSelect
+    // 有 VM/delegate 侧的生产者,必须留在 Effect 里。
+
+    override fun pageChanged() {
+        handler.post {
+            this.pageChanged = true
+            refs?.readView?.onPageChange()
+            viewModel.startBackupJob()
+        }
+    }
+
+    override fun contentLoadFinish() {
+        // isInitFinish 是纯业务/UI 标志(决定 ReadView 是否放行前后章排版),不属于渲染
+        viewModel.markInitFinished()
+        handler.post {
+            viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
+            onStartContentLoadFinish?.invoke()
+        }
+    }
+
+    override fun upPageAnim(upRecorder: Boolean) {
+        postRender(ReadBookEffect.UpPageAnim(upRecorder))
+    }
+
+    override fun cancelSelect() {
+        postRender(ReadBookEffect.CancelSelect)
+    }
+
+    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
+        handler.post {
+            layoutController.publishPageLayout(index)
+            upSeekBarThrottle.invoke()
+            refs?.readView?.onLayoutPageCompleted(index, page)
+        }
+    }
+
     // ── Effect handling ───────────────────────────────────────────────
 
     /**
@@ -836,6 +1007,11 @@ class ReadBookController(
             is ReadBookEffect.Finish -> closeReadBook()
             is ReadBookEffect.UpdateReadViewConfig -> {
                 val r = refs ?: return
+                // 两份快照都是配置的纯派生,在分发具体 action 之前先重建:下划线/虚线/下划线颜色
+                // 等项的 action 集里并没有 UpdateStyle,靠 upStyle() 顺带重建会漏;而且
+                // actions 是集合,无法保证「重建」排在 InvalidateTextPage/SubmitRenderTask 之前。
+                ChapterProvider.upRenderStyle()
+                TipStyleProvider.upTipStyle()
                 effect.actions.forEach { action ->
                     when (action) {
                         ConfigUpdateAction.UpdateSystemUi -> upSystemUiVisibility()
@@ -844,7 +1020,9 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
-                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
+                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
+                            layoutController.requestRelayout()
+                        }
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateChapterStyle -> {
                             ChapterProvider.upStyle()
@@ -927,22 +1105,6 @@ class ReadBookController(
                 ReadBook.book?.let { viewModel.refreshContentDur(it) }
             }
 
-            is ReadBookEffect.PageChanged -> {
-                pageChanged = true
-                refs?.readView?.onPageChange()
-                viewModel.startBackupJob()
-            }
-
-            is ReadBookEffect.LayoutPageCompleted -> {
-                upSeekBarThrottle.invoke()
-                refs?.readView?.onLayoutPageCompleted(effect.index, effect.page)
-            }
-
-            is ReadBookEffect.ContentLoadFinish -> {
-                viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
-                onStartContentLoadFinish?.invoke()
-            }
-
             is ReadBookEffect.UpScreenTimeOut -> {
                 upScreenTimeOut()
             }
@@ -963,7 +1125,7 @@ class ReadBookController(
             is ReadBookEffect.StopAutoPage -> onStopAutoPage?.invoke() ?: stopAutoPage()
             is ReadBookEffect.TextActionAloudSelect -> {
                 activity.lifecycleScope.launch {
-                    refs?.readView?.aloudStartSelect(effect.selectStartPos.copy())
+                    aloudStartSelect(effect.selectStartPos.copy())
                 }
             }
 
@@ -1073,9 +1235,6 @@ class ReadBookController(
             is ReadBookEffect.OpenMenuCustomIconPicker,
             is ReadBookEffect.OpenTitleBarCustomIconPicker,
             is ReadBookEffect.OpenSystemTtsSettings,
-            is ReadBookEffect.OpenHttpTtsImportPicker,
-            is ReadBookEffect.OpenHttpTtsExportPicker,
-            is ReadBookEffect.OpenHttpTtsLogin,
             ReadBookEffect.OpenTtsEnginesAndVoices,
             ReadBookEffect.OpenTtsCache,
             is ReadBookEffect.OpenBookVoiceCasting,
@@ -1122,7 +1281,7 @@ class ReadBookController(
                                 ReadBook.readAloud(startPos = line.pagePosition)
                             }
                         } else {
-                            ReadBook.durChapterPos = line.chapterPosition
+                            ReadBook.updateReadingPosition(line.chapterPosition)
                             ReadBook.readAloud(startPos = line.pagePosition)
                         }
                     } else {
@@ -1146,7 +1305,7 @@ class ReadBookController(
                                 ReadBook.readAloud(startPos = line.pagePosition)
                             }
                         } else {
-                            ReadBook.durChapterPos = line.chapterPosition
+                            ReadBook.updateReadingPosition(line.chapterPosition)
                             ReadBook.readAloud(startPos = line.pagePosition)
                         }
                     } else {

+ 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()
+        }
+    }
+
+}

+ 54 - 37
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -6,8 +6,10 @@ import android.hardware.Sensor
 import android.hardware.SensorEvent
 import android.hardware.SensorEventListener
 import android.hardware.SensorManager
+import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.View
+import android.view.ViewTreeObserver
 import android.widget.FrameLayout
 import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
@@ -53,6 +55,8 @@ import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
+import io.legado.app.ui.book.read.page.ReaderEventListener
+import io.legado.app.ui.book.read.page.ReaderPageSource
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
@@ -90,6 +94,8 @@ data class ReadBookViewRefs(
 interface ReadBookRouteHost :
     View.OnTouchListener,
     ReadView.CallBack,
+    ReaderEventListener,
+    ReaderPageSource,
     ContentTextView.CallBack {
 
     val isInMultiWindowModeCompat: Boolean
@@ -132,6 +138,10 @@ fun ReadBookRouteScreen(
     onOpenTtsCache: () -> Unit = {},
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val aiState by viewModel.aiState.collectAsStateWithLifecycle()
+    val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
+    val contentEditState by viewModel.contentEditState.collectAsStateWithLifecycle()
+    val contentProcessState by viewModel.contentProcessState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
@@ -273,18 +283,6 @@ fun ReadBookRouteScreen(
         }
     }
 
-    val importHttpTtsPicker = rememberLauncherForActivityResult(
-        ActivityResultContracts.OpenDocument()
-    ) { uri ->
-        uri?.let { viewModel.onIntent(ReadBookIntent.ImportHttpTtsFileSelected(it)) }
-    }
-
-    val exportHttpTtsPicker = rememberLauncherForActivityResult(
-        ActivityResultContracts.CreateDocument("application/json")
-    ) { uri ->
-        uri?.let { viewModel.onIntent(ReadBookIntent.ExportHttpTtsToFile(it)) }
-    }
-
     val importHighlightRulePicker = rememberLauncherForActivityResult(
         ActivityResultContracts.OpenDocument()
     ) { uri ->
@@ -342,14 +340,6 @@ fun ReadBookRouteScreen(
                                     }
                                 )
                             }
-                            is ReadBookEffect.OpenHttpTtsLogin -> {
-                                context.startActivity(
-                                    Intent(context, SourceLoginActivity::class.java).apply {
-                                        putExtra("type", "httpTts")
-                                        putExtra("key", effect.engineId.toString())
-                                    }
-                                )
-                            }
                             is ReadBookEffect.OpenWebView -> {
                                 context.startActivity(
                                     Intent(context, WebViewActivity::class.java).apply {
@@ -450,19 +440,6 @@ fun ReadBookRouteScreen(
                             is ReadBookEffect.TtsCacheCleared -> {
                                 context.toastOnUi(effect.message)
                             }
-                            is ReadBookEffect.OpenHttpTtsImportPicker -> {
-                                importHttpTtsPicker.launch(
-                                    arrayOf(
-                                        "application/json",
-                                        "text/plain"
-                                    )
-                                )
-                            }
-
-                            is ReadBookEffect.OpenHttpTtsExportPicker -> {
-                                exportHttpTtsPicker.launch("httpTTS.json")
-                            }
-
                             is ReadBookEffect.OpenHighlightRuleImportPicker -> {
                                 importHighlightRulePicker.launch(
                                     arrayOf(
@@ -532,20 +509,38 @@ fun ReadBookRouteScreen(
 
     var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
 
-    Box(
-        Modifier.fillMaxSize()
-    ) {
+    val firstFrameTracker = remember(controller) {
+        val requestedStart = controller.activity.intent.getLongExtra(
+            EXTRA_FIRST_FRAME_STARTED_AT_NANOS,
+            0L,
+        )
+        ReaderFirstFrameTracker(
+            startedAtNanos = requestedStart.takeIf { it > 0L }
+                ?: SystemClock.elapsedRealtimeNanos(),
+        )
+    }
+
+    Box(Modifier.fillMaxSize()) {
         key(controller) {
             ReadBookViewLayer(
                 modifier = Modifier
-                    .then(if (useMenuHazeSource) Modifier.hazeSource(menuHazeState) else Modifier)
+                    .then(
+                        if (useMenuHazeSource) {
+                            Modifier.hazeSource(menuHazeState)
+                        } else {
+                            Modifier
+                        }
+                    )
                     .layerBackdrop(menuBackdrop),
                 onRefsReady = { controller.onRefsReady(it) },
                 onCursorTouch = controller,
                 readViewCallBack = controller,
+                readerEventListener = controller,
+                readerPageSource = controller,
                 contentTextViewCallBack = controller,
                 isDarkTheme = isDarkTheme,
                 onThemeChanged = controller::onAppThemeChanged,
+                onFirstContentDrawn = firstFrameTracker::report,
             )
         }
         ReadBookColorTheme(
@@ -597,6 +592,10 @@ fun ReadBookRouteScreen(
             }
             ReadBookScreen(
                 state = state,
+                aiState = aiState,
+                highlightRuleState = highlightRuleState,
+                contentEditState = contentEditState,
+                contentProcessState = contentProcessState,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },
@@ -671,9 +670,12 @@ private fun ReadBookViewLayer(
     onRefsReady: (ReadBookViewRefs) -> Unit,
     onCursorTouch: View.OnTouchListener,
     readViewCallBack: ReadView.CallBack,
+    readerEventListener: ReaderEventListener,
+    readerPageSource: ReaderPageSource,
     contentTextViewCallBack: ContentTextView.CallBack,
     isDarkTheme: Boolean,
     onThemeChanged: (Boolean) -> Unit,
+    onFirstContentDrawn: () -> Unit,
 ) {
     AndroidView(
         modifier = modifier.fillMaxSize(),
@@ -684,6 +686,8 @@ private fun ReadBookViewLayer(
                     context = context,
                     callBack = readViewCallBack,
                     contentCallBack = contentTextViewCallBack,
+                    eventListener = readerEventListener,
+                    pageSource = readerPageSource,
                 ).apply {
                     layoutParams = FrameLayout.LayoutParams(
                         FrameLayout.LayoutParams.MATCH_PARENT,
@@ -742,6 +746,19 @@ private fun ReadBookViewLayer(
                         navigationBar = navigationBar,
                     )
                 )
+                val root = this
+                val firstDrawListener = object : ViewTreeObserver.OnDrawListener {
+                    override fun onDraw() {
+                        if (readView.curPage.textPage.lines.isEmpty()) return
+                        onFirstContentDrawn()
+                        root.post {
+                            if (root.viewTreeObserver.isAlive) {
+                                root.viewTreeObserver.removeOnDrawListener(this)
+                            }
+                        }
+                    }
+                }
+                viewTreeObserver.addOnDrawListener(firstDrawListener)
             }
         },
         update = {

+ 11 - 7
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -66,6 +66,10 @@ import io.legado.app.model.BookCover as BookCoverModel
 @Composable
 fun ReadBookScreen(
     state: ReadBookUiState,
+    aiState: ReadAiUiState,
+    highlightRuleState: HighlightRuleConfigUiState,
+    contentEditState: ContentEditUiState,
+    contentProcessState: ContentProcessConfigUiState,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
@@ -195,7 +199,7 @@ fun ReadBookScreen(
     )
     ContentProcessesSheet(
         show = state.activeSheet is ReadBookSheet.ContentProcesses,
-        state = state.contentProcessConfig,
+        state = contentProcessState,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
@@ -263,38 +267,38 @@ fun ReadBookScreen(
     )
     HighlightRuleConfigSheet(
         show = state.activeSheet is ReadBookSheet.HighlightRuleConfig,
-        state = state.highlightRuleConfig,
+        state = highlightRuleState,
         allConfigNames = state.sheetConfig.configNames,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
     )
     ContentEditSheet(
         show = state.activeSheet is ReadBookSheet.ContentEdit,
-        state = state,
+        state = contentEditState,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     ChapterSummarySheet(
         show = state.activeSheet is ReadBookSheet.ChapterSummary,
-        state = state.chapterSummary,
+        state = aiState.chapterSummary,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiTextCleanSheet(
         show = state.activeSheet is ReadBookSheet.AiTextClean,
-        state = state.aiTextClean,
+        state = aiState.aiTextClean,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiTextRewriteSheet(
         show = state.activeSheet is ReadBookSheet.AiTextRewrite,
-        state = state.aiTextRewrite,
+        state = aiState.aiTextRewrite,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiRewritePresetConfigSheet(
         show = state.activeSheet is ReadBookSheet.AiRewritePresetConfig,
-        state = state.aiRewritePresetConfig,
+        state = aiState.aiRewritePresetConfig,
         onIntent = onIntent,
         onDismissRequest = { onIntent(ReadBookIntent.CloseAiRewritePresetConfig) },
     )

Разница между файлами не показана из-за своего большого размера
+ 505 - 332
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)
+        }
+    }
+}

+ 159 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt

@@ -0,0 +1,159 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import io.legado.app.help.coroutine.Coroutine
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import java.io.File
+
+/**
+ * 阅读菜单按钮配置域(R2.2 续批)。
+ *
+ * 管顶栏/底栏按钮的排序与开关,以及两处自定义图标的落盘。按钮列表本身仍住在
+ * [ReadMenuConfig] 里(菜单栏各处直读,搬出去要改一大片 UI),故本 delegate
+ * **无自持状态**,和 [ReadConfigUpdateDelegate] 同形:一切读写经 [Host]。
+ */
+class ReadButtonConfigDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+) {
+
+    interface Host {
+        fun updateMenuConfig(transform: (ReadMenuConfig) -> ReadMenuConfig)
+
+        /** 自定义图标落盘后走配置更新分发,通知菜单重读。 */
+        fun applyConfigUpdate(update: ConfigUpdate)
+    }
+
+    /** 从 SharedPreferences 读回两条按钮配置,刷进 menuConfig。VM 构造时调一次。 */
+    fun refresh() {
+        val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY)
+        val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY)
+        host.updateMenuConfig {
+            it.copy(
+                titleBarButtons = titleBarButtons.toImmutableList(),
+                bottomBarButtons = bottomBarButtons.toImmutableList(),
+            )
+        }
+    }
+
+    fun saveTitleBarButtons(items: List<ReadBookButtonConfigItem>) {
+        val normalized = normalizeButtonConfig(items)
+        saveButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY, normalized)
+        host.updateMenuConfig { it.copy(titleBarButtons = normalized.toImmutableList()) }
+    }
+
+    fun saveMenuButtons(items: List<ReadBookButtonConfigItem>) {
+        val normalized = normalizeButtonConfig(items)
+        saveButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY, normalized)
+        host.updateMenuConfig { it.copy(bottomBarButtons = normalized.toImmutableList()) }
+    }
+
+    fun saveMenuCustomIcon(id: String, uri: Uri) {
+        copyCustomIcon(id, uri, "read_menu_icons") { path ->
+            ConfigUpdate.MenuCustomIcon(id, path)
+        }
+    }
+
+    fun saveTitleBarCustomIcon(id: String, uri: Uri) {
+        copyCustomIcon(id, uri, "title_bar_icons") { path ->
+            ConfigUpdate.TitleBarCustomIcon(id, path)
+        }
+    }
+
+    private fun copyCustomIcon(
+        id: String,
+        uri: Uri,
+        directoryName: String,
+        update: (path: String) -> ConfigUpdate,
+    ) {
+        Coroutine.async(scope, Dispatchers.IO) {
+            val iconFile = File(context.filesDir, "$directoryName/$id.png")
+            iconFile.parentFile?.mkdirs()
+            context.contentResolver.openInputStream(uri)?.use { input ->
+                iconFile.outputStream().use { output -> input.copyTo(output) }
+            }
+            host.applyConfigUpdate(update(iconFile.absolutePath))
+        }
+    }
+
+    private fun loadButtonConfig(
+        preferenceName: String,
+        key: String,
+    ): List<ReadBookButtonConfigItem> {
+        val prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
+        val raw = prefs.getString(key, null)
+            ?.split(";")
+            ?.mapNotNull { token ->
+                val parts = token.split(",")
+                val id = parts.getOrNull(0)?.takeIf { it in ReadBookButtonIds }
+                val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull()
+                if (id != null && enabled != null) {
+                    ReadBookButtonConfigItem(id, enabled)
+                } else {
+                    null
+                }
+            }
+            ?: emptyList()
+
+        return if (raw.isEmpty()) {
+            ReadBookButtonIds.map { id ->
+                ReadBookButtonConfigItem(
+                    id = id,
+                    enabled = id in DEFAULT_ENABLED_BUTTON_IDS,
+                )
+            }
+        } else {
+            normalizeButtonConfig(raw)
+        }
+    }
+
+    private fun saveButtonConfig(
+        preferenceName: String,
+        key: String,
+        items: List<ReadBookButtonConfigItem>,
+    ) {
+        val value = items.joinToString(";") { "${it.id},${it.enabled}" }
+        context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
+            .edit()
+            .putString(key, value)
+            .apply()
+    }
+
+    private fun normalizeButtonConfig(
+        items: List<ReadBookButtonConfigItem>,
+    ): List<ReadBookButtonConfigItem> {
+        val seen = mutableSetOf<String>()
+        val normalized = items.mapNotNull { item ->
+            val id = item.id
+            if (id in ReadBookButtonIds && seen.add(id)) {
+                ReadBookButtonConfigItem(id, item.enabled)
+            } else {
+                null
+            }
+        }.toMutableList()
+        ReadBookButtonIds.forEach { id ->
+            if (seen.add(id)) {
+                normalized.add(ReadBookButtonConfigItem(id, false))
+            }
+        }
+        return normalized
+    }
+
+    private companion object {
+        const val TITLE_BAR_ICON_PREFS = "title_bar_icons"
+        const val TITLE_BAR_ICON_KEY = "icons"
+        const val TOOL_BUTTON_PREFS = "tool_button_config"
+        const val TOOL_BUTTON_KEY = "tool_buttons"
+        val DEFAULT_ENABLED_BUTTON_IDS = setOf(
+            "search",
+            "auto_page",
+            "catalog",
+            "read_aloud",
+            "setting",
+        )
+    }
+}

+ 21 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateBus.kt

@@ -0,0 +1,21 @@
+package io.legado.app.ui.book.read
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+/**
+ * Typed replacement for the legacy `EventBus.UP_CONFIG` integer-code channel.
+ * Producers post [ConfigUpdateAction] sets; [ReadBookViewModel] collects and
+ * forwards them to the ReadView as [ReadBookEffect.UpdateReadViewConfig].
+ */
+object ReadConfigUpdateBus {
+
+    private val _events = MutableSharedFlow<Set<ConfigUpdateAction>>(extraBufferCapacity = 64)
+    val events: SharedFlow<Set<ConfigUpdateAction>> = _events
+
+    fun post(actions: Set<ConfigUpdateAction>) {
+        if (actions.isNotEmpty()) {
+            _events.tryEmit(actions)
+        }
+    }
+}

+ 842 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt

@@ -0,0 +1,842 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.constant.EventBus
+import io.legado.app.constant.PreferKey
+import io.legado.app.constant.ReadMenuBlurMode
+import io.legado.app.domain.gateway.ReadStyleGateway
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.domain.gateway.ReadStyleBooleanKey
+import io.legado.app.domain.gateway.ReadStyleColorKey
+import io.legado.app.domain.gateway.ReadStyleFloatKey
+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.help.config.ReadBookConfig
+import io.legado.app.model.ReadSessionState
+import io.legado.app.utils.postEvent
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * 阅读页配置更新的分发器:把一条 [ConfigUpdate] 落到对应的 repository,并发出相应 effect。
+ *
+ * **无自持状态**——和 AI/高亮/正文编辑三个域不同,这里没有属于自己的 UiState。
+ * 它写的 `menuConfig` 是阅读器 chrome 的状态,被菜单栏读,所以留在 [ReadBookUiState],
+ * 经 [Host.updateMenuConfig] 回写。摘出来的收益是 780 行离开 god object、
+ * 158 分支的配置分发变成可独立审阅的一块。
+ *
+ * 纯排版项由 [toReadStyleMutation] 转成 [ReadStyleMutation] 交给 repository;
+ * 转不出 mutation 的(只写 DataStore 的项)走 [Host.refreshConfigSnapshots] ——
+ * 这是排版快照三条重建通道里的第三条,删之前先看 `collectReadStyle` 上的注释。
+ */
+class ReadConfigUpdateDelegate(
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val readSettingsRepository: ReadSettingsRepository,
+    private val readBookStyleConfigRepository: ReadStyleGateway,
+) {
+
+    interface Host {
+        /** 当前菜单配置快照,个别分支要读旧值做回退判断。 */
+        val menuConfig: ReadMenuConfig
+
+        fun updateMenuConfig(transform: (ReadMenuConfig) -> ReadMenuConfig)
+
+        /** 重建 styleConfig + sheetConfig 两份快照(只写 DataStore 的配置项走这条)。 */
+        fun refreshConfigSnapshots()
+
+        fun emitEffect(effect: ReadBookEffect)
+
+        /** 重新打开「自动建议日夜」时,清掉本次会话已忽略提醒的标记。 */
+        fun resetDayNightReminderDismissal()
+    }
+
+    fun handle(update: ConfigUpdate) {
+        val styleMutation = update.toReadStyleMutation()
+        styleMutation?.let(readBookStyleConfigRepository::updateCurrentStyle)
+        when (update) {
+            // --- Text style ---
+            is ConfigUpdate.TextSize,
+            is ConfigUpdate.LetterSpacing,
+            is ConfigUpdate.LineSpacing,
+            is ConfigUpdate.ParagraphSpacing,
+            is ConfigUpdate.ParagraphIndent,
+            is ConfigUpdate.TextItalic,
+            is ConfigUpdate.TextBold,
+            is ConfigUpdate.TextColor,
+            is ConfigUpdate.TextAccentColor -> Unit
+
+            // --- Title style ---
+            is ConfigUpdate.TitleMode,
+            is ConfigUpdate.TitleBold,
+            is ConfigUpdate.TitleSegScaling,
+            is ConfigUpdate.TitleLineSpacingExtra,
+            is ConfigUpdate.TitleLineSpacingSub,
+            is ConfigUpdate.TitleSize,
+            is ConfigUpdate.TitleTopSpacing,
+            is ConfigUpdate.TitleBottomSpacing,
+            is ConfigUpdate.TitleColor,
+            is ConfigUpdate.TitleColorNight,
+            is ConfigUpdate.TitleFont,
+            is ConfigUpdate.TitleSegType,
+            is ConfigUpdate.TitleSegDistance,
+            is ConfigUpdate.TitleSegFlag -> Unit
+
+            // --- Header / footer tips ---
+            is ConfigUpdate.HeaderMode,
+            is ConfigUpdate.FooterMode,
+            is ConfigUpdate.TipHeaderLeft,
+            is ConfigUpdate.TipHeaderMiddle,
+            is ConfigUpdate.TipHeaderRight,
+            is ConfigUpdate.TipFooterLeft,
+            is ConfigUpdate.TipFooterMiddle,
+            is ConfigUpdate.TipFooterRight,
+            is ConfigUpdate.CustomTipHeaderLeft,
+            is ConfigUpdate.CustomTipHeaderMiddle,
+            is ConfigUpdate.CustomTipHeaderRight,
+            is ConfigUpdate.CustomTipFooterLeft,
+            is ConfigUpdate.CustomTipFooterMiddle,
+            is ConfigUpdate.CustomTipFooterRight,
+            is ConfigUpdate.HeaderFont,
+            is ConfigUpdate.HeaderFontSize,
+            is ConfigUpdate.FooterFont,
+            is ConfigUpdate.FooterFontSize,
+            is ConfigUpdate.ApplyHeaderStyle,
+            is ConfigUpdate.TipHeaderColor,
+            is ConfigUpdate.TipHeaderColorNight,
+            is ConfigUpdate.TipFooterColor,
+            is ConfigUpdate.TipFooterColorNight,
+            is ConfigUpdate.TipDividerColor -> Unit
+
+            // --- Layout / style ---
+            is ConfigUpdate.StyleSelect -> {
+                scope.launch {
+                    readSettingsRepository.setStyleSelect(ReadSessionState.isComic, update.index)
+                }
+            }
+            is ConfigUpdate.ShareLayout -> {
+                scope.launch {
+                    readSettingsRepository.setShareLayout(update.value)
+                }
+            }
+            is ConfigUpdate.PageAnim -> Unit
+
+            // --- Menu appearance ---
+            is ConfigUpdate.MenuBgColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBgColor(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuAccentColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuAccentColor(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuContainerColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuContainerColor(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuBgColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBgColorNight(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuAccentColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuAccentColorNight(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuContainerColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuContainerColorNight(update.color)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.MenuTextColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuTextColor(update.color)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuTextColor = update.color)
+                }
+            }
+            is ConfigUpdate.MenuTextColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuTextColorNight(update.color)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuTextColorNight = update.color)
+                }
+            }
+            is ConfigUpdate.MenuColorMode -> {
+                val value = update.value.coerceIn(0, 1)
+                scope.launch {
+                    readSettingsRepository.setReadMenuColorMode(value)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.ReadBarStyle -> {
+                val value = update.value.coerceIn(0, 2)
+                scope.launch {
+                    readSettingsRepository.setReadBarStyle(value)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+
+            // --- Menu bar border ---
+            is ConfigUpdate.BorderWidth -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBorderWidth(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuBorderWidth = update.value) }
+            }
+            is ConfigUpdate.BorderColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBorderColor(update.color)
+                }
+                host.updateMenuConfig { it.copy(readMenuBorderColor = update.color) }
+            }
+            is ConfigUpdate.BorderColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBorderColorNight(update.color)
+                }
+                host.updateMenuConfig { it.copy(readMenuBorderColorNight = update.color) }
+            }
+
+            // --- Shadow ---
+            is ConfigUpdate.TextShadow,
+            is ConfigUpdate.ShadowRadius,
+            is ConfigUpdate.ShadowDx,
+            is ConfigUpdate.ShadowDy,
+            is ConfigUpdate.ShadowColor -> Unit
+
+            // --- Underline ---
+            is ConfigUpdate.Underline,
+            is ConfigUpdate.DottedLine,
+            is ConfigUpdate.UnderlineExtend,
+            is ConfigUpdate.UnderlineHeight,
+            is ConfigUpdate.UnderlinePadding,
+            is ConfigUpdate.DottedBase,
+            is ConfigUpdate.DottedRatio,
+            is ConfigUpdate.UnderlineColor -> Unit
+
+            // --- Body padding ---
+            is ConfigUpdate.PaddingTop,
+            is ConfigUpdate.PaddingBottom,
+            is ConfigUpdate.PaddingLeft,
+            is ConfigUpdate.PaddingRight -> Unit
+
+            // --- Header padding ---
+            is ConfigUpdate.HeaderPaddingTop,
+            is ConfigUpdate.HeaderPaddingBottom,
+            is ConfigUpdate.HeaderPaddingLeft,
+            is ConfigUpdate.HeaderPaddingRight,
+            is ConfigUpdate.ShowHeaderLine -> Unit
+
+            // --- Footer padding ---
+            is ConfigUpdate.FooterPaddingTop,
+            is ConfigUpdate.FooterPaddingBottom,
+            is ConfigUpdate.FooterPaddingLeft,
+            is ConfigUpdate.FooterPaddingRight,
+            is ConfigUpdate.ShowFooterLine -> Unit
+
+            // --- Background / display ---
+            is ConfigUpdate.BgStr,
+            is ConfigUpdate.BgStrNight,
+            is ConfigUpdate.BgStrEInk,
+            is ConfigUpdate.BgType,
+            is ConfigUpdate.BgTypeNight,
+            is ConfigUpdate.BgTypeEInk,
+            is ConfigUpdate.BgAlpha,
+            is ConfigUpdate.StatusIconDark,
+            is ConfigUpdate.StyleName -> Unit
+            is ConfigUpdate.MenuIconShowText -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuIconShowText(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuIconShowText = update.value) }
+            }
+            is ConfigUpdate.MenuIconStyle -> {
+                val value = update.value.coerceIn(0, 2)
+                scope.launch {
+                    readSettingsRepository.setReadMenuIconStyle(value)
+                }
+                host.updateMenuConfig { it.copy(readMenuIconStyle = value) }
+            }
+            is ConfigUpdate.TitleBarIconStyle -> {
+                val value = update.value.coerceIn(0, 2)
+                scope.launch {
+                    readSettingsRepository.setTitleBarIconStyle(value)
+                }
+                host.updateMenuConfig { it.copy(titleBarIconStyle = value) }
+            }
+            is ConfigUpdate.MenuIconItemsPerRow -> {
+                val value = update.value.coerceIn(2, 8)
+                scope.launch {
+                    readSettingsRepository.setReadMenuIconItemsPerRow(value)
+                }
+                host.updateMenuConfig { it.copy(readMenuIconItemsPerRow = value) }
+            }
+            is ConfigUpdate.MenuIconRowCount -> {
+                val value = update.value.coerceIn(1, 2)
+                scope.launch {
+                    readSettingsRepository.setReadMenuIconRowCount(value)
+                }
+                host.updateMenuConfig { it.copy(readMenuIconRowCount = value) }
+            }
+            is ConfigUpdate.MenuBottomCornerRadius -> {
+                val value = update.value.coerceIn(0, 32)
+                scope.launch {
+                    readSettingsRepository.setReadMenuBottomCornerRadius(value)
+                }
+                host.updateMenuConfig { it.copy(readMenuBottomCornerRadius = value) }
+            }
+            is ConfigUpdate.FloatingBottomBar -> {
+                val needsBlurFallback = !update.value &&
+                        host.menuConfig.readMenuBottomBarBlurMode ==
+                        ReadMenuBlurMode.LiquidGlass
+                scope.launch {
+                    readSettingsRepository.setReadMenuFloatingBottomBar(update.value)
+                    if (needsBlurFallback) {
+                        readSettingsRepository.setReadMenuBottomBarBlurMode(ReadMenuBlurMode.Haze)
+                    }
+                }
+                host.updateMenuConfig {
+                    it.copy(
+                        readMenuFloatingBottomBar = update.value,
+                        readMenuBottomBarBlurMode = if (needsBlurFallback) ReadMenuBlurMode.Haze
+                        else it.readMenuBottomBarBlurMode,
+                    )
+                }
+            }
+            is ConfigUpdate.ShowMenuIcon -> {
+                scope.launch {
+                    readSettingsRepository.setShowMenuIcon(update.value)
+                }
+                host.updateMenuConfig { it.copy(showMenuIcon = update.value) }
+            }
+            is ConfigUpdate.MenuTopBarBlurMode -> {
+                val mode = update.value.coerceIn(0, 2).let {
+                    if (it == ReadMenuBlurMode.LiquidGlass) ReadMenuBlurMode.Haze else it
+                }
+                scope.launch {
+                    readSettingsRepository.setReadMenuTopBarBlurMode(mode)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuTopBarBlurMode = mode)
+                }
+            }
+
+            is ConfigUpdate.MenuBottomBarBlurMode -> {
+                val mode = update.value.coerceIn(0, 2)
+                scope.launch {
+                    readSettingsRepository.setReadMenuBottomBarBlurMode(mode)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuBottomBarBlurMode = mode)
+                }
+            }
+
+            is ConfigUpdate.MenuTopBarLiquidGlassButtons -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuTopBarLiquidGlassButtons(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuTopBarLiquidGlassButtons = update.value)
+                }
+            }
+
+            is ConfigUpdate.MenuTopBarTitleCapsule -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuTopBarTitleCapsule(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuTopBarTitleCapsule = update.value)
+                }
+            }
+
+            is ConfigUpdate.MenuBottomBarLiquidGlassButtons -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBottomBarLiquidGlassButtons(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuBottomBarLiquidGlassButtons = update.value)
+                }
+            }
+
+            is ConfigUpdate.MenuFloatingIconLiquidGlass -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuFloatingIconLiquidGlass(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuFloatingIconLiquidGlass = update.value)
+                }
+            }
+
+            is ConfigUpdate.MenuTopBarBlurSelection -> {
+                val mode = update.mode.coerceIn(0, 2).let {
+                    if (it == ReadMenuBlurMode.LiquidGlass) ReadMenuBlurMode.Haze else it
+                }
+                val style = update.style.coerceIn(0, 1)
+                scope.launch {
+                    readSettingsRepository.setReadMenuTopBarBlurMode(mode)
+                    readSettingsRepository.setReadMenuTopBarBlurStyle(style)
+                }
+                host.updateMenuConfig {
+                    it.copy(
+                        readMenuTopBarBlurMode = mode,
+                        readMenuTopBarBlurStyle = style,
+                    )
+                }
+            }
+
+            is ConfigUpdate.MenuBottomBarBlurStyle -> {
+                val style = update.value.coerceIn(0, 1)
+                scope.launch {
+                    readSettingsRepository.setReadMenuBottomBarBlurStyle(style)
+                }
+                host.updateMenuConfig {
+                    it.copy(readMenuBottomBarBlurStyle = style)
+                }
+            }
+            is ConfigUpdate.MenuBlurRadius -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBlurRadius(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuBlurRadius = update.value) }
+            }
+            is ConfigUpdate.MenuBlurAlpha -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBlurAlpha(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuBlurAlpha = update.value) }
+            }
+            is ConfigUpdate.MenuBlurColor -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBlurColor(update.color)
+                }
+                host.updateMenuConfig { it.copy(readMenuBlurColor = update.color) }
+            }
+            is ConfigUpdate.MenuBlurColorNight -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuBlurColorNight(update.color)
+                }
+                host.updateMenuConfig { it.copy(readMenuBlurColorNight = update.color) }
+            }
+            is ConfigUpdate.MenuPaletteStyle -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuPaletteStyle(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuPaletteStyle = update.value) }
+            }
+            is ConfigUpdate.MenuLensRadius -> {
+                scope.launch {
+                    readSettingsRepository.setReadMenuLensRadius(update.value)
+                }
+                host.updateMenuConfig { it.copy(readMenuLensRadius = update.value) }
+            }
+            is ConfigUpdate.MenuCustomIcon -> {
+                val icons = ReadBookConfig.readMenuCustomIcons.toMutableMap()
+                if (update.path.isBlank()) {
+                    icons.remove(update.id)?.let { path ->
+                        runCatching { java.io.File(path).delete() }
+                    }
+                } else {
+                    icons[update.id] = update.path
+                }
+                scope.launch {
+                    readSettingsRepository.setReadMenuCustomIcons(
+                        ReadBookConfig.encodeReadMenuCustomIcons(icons)
+                    )
+                }
+                host.updateMenuConfig { it.copy(readMenuCustomIcons = icons.toImmutableMap()) }
+            }
+            is ConfigUpdate.TitleBarCustomIcon -> {
+                val icons = ReadBookConfig.titleBarCustomIcons.toMutableMap()
+                if (update.path.isBlank()) {
+                    icons.remove(update.id)?.let { path ->
+                        runCatching { java.io.File(path).delete() }
+                    }
+                } else {
+                    icons[update.id] = update.path
+                }
+                scope.launch {
+                    readSettingsRepository.setTitleBarCustomIcons(
+                        ReadBookConfig.encodeReadMenuCustomIcons(icons)
+                    )
+                }
+                host.updateMenuConfig { it.copy(titleBarCustomIcons = icons.toImmutableMap()) }
+            }
+            is ConfigUpdate.TitleBarIconPosition -> {
+                scope.launch {
+                    readSettingsRepository.setTitleBarIconPosition(update.value)
+                }
+                host.updateMenuConfig { it.copy(titleBarIconPosition = update.value) }
+            }
+            is ConfigUpdate.ShowTitleBarIcons -> {
+                scope.launch {
+                    readSettingsRepository.setShowTitleBarIcons(update.value)
+                }
+                host.updateMenuConfig { it.copy(showTitleBarIcons = update.value) }
+            }
+            is ConfigUpdate.TitleBarCompact -> {
+                scope.launch {
+                    readSettingsRepository.update { it.copy(titleBarCompact = update.value) }
+                }
+                host.updateMenuConfig { it.copy(titleBarCompact = update.value) }
+            }
+
+            // --- System UI (also persists to DataStore) ---
+            is ConfigUpdate.HideStatusBar -> {
+                scope.launch {
+                    readSettingsRepository.setHideStatusBar(update.value)
+                }
+            }
+            is ConfigUpdate.HideNavigationBar -> {
+                scope.launch {
+                    readSettingsRepository.setHideNavigationBar(update.value)
+                }
+            }
+
+            // --- Display toggles ---
+            is ConfigUpdate.PaddingDisplayCutouts -> {
+                scope.launch {
+                    readSettingsRepository.setPaddingDisplayCutouts(update.value)
+                }
+            }
+            is ConfigUpdate.TitleBarMode -> {
+                scope.launch {
+                    readSettingsRepository.setTitleBarMode(update.value)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+            is ConfigUpdate.ReadBodyToLh -> {
+                scope.launch {
+                    readSettingsRepository.setReadBodyToLh(update.value)
+                }
+            }
+            is ConfigUpdate.DefaultSourceChangeAll -> {
+                scope.launch {
+                    readSettingsRepository.setDefaultSourceChangeAll(update.value)
+                }
+            }
+            is ConfigUpdate.TextFullJustify -> {
+                scope.launch {
+                    readSettingsRepository.setTextFullJustify(update.value)
+                }
+            }
+            is ConfigUpdate.TextBottomJustify -> {
+                scope.launch {
+                    readSettingsRepository.setTextBottomJustify(update.value)
+                }
+            }
+            is ConfigUpdate.AdaptSpecialStyle -> {
+                scope.launch {
+                    readSettingsRepository.setAdaptSpecialStyle(update.value)
+                }
+            }
+            is ConfigUpdate.UseZhLayout -> {
+                scope.launch {
+                    readSettingsRepository.setUseZhLayout(update.value)
+                }
+            }
+            is ConfigUpdate.ShowBrightnessView -> {
+                scope.launch {
+                    readSettingsRepository.setShowBrightnessView(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(showBrightnessView = update.value)
+                }
+                postEvent(PreferKey.showBrightnessView, "")
+            }
+
+            is ConfigUpdate.BrightnessVwPos -> {
+                scope.launch {
+                    readSettingsRepository.setBrightnessVwPos(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(brightnessVwPos = update.value)
+                }
+            }
+
+            is ConfigUpdate.BrightnessAuto -> {
+                host.updateMenuConfig {
+                    it.copy(brightnessAuto = update.value)
+                }
+                scope.launch {
+                    readSettingsRepository.setBrightnessAuto(update.value)
+                }
+                host.emitEffect(
+                    ReadBookEffect.ToggleBrightnessAuto(
+                        update.value,
+                        host.menuConfig.readBrightness
+                    )
+                )
+            }
+            is ConfigUpdate.UseUnderlineGlobal -> {
+                scope.launch {
+                    readSettingsRepository.setUseUnderline(update.value)
+                }
+            }
+            is ConfigUpdate.ReadSliderMode -> {
+                scope.launch {
+                    readSettingsRepository.setReadSliderMode(update.value)
+                }
+                host.updateMenuConfig {
+                    it.copy(readSliderMode = update.value)
+                }
+            }
+            is ConfigUpdate.DoubleHorizontalPage -> {
+                scope.launch {
+                    readSettingsRepository.setDoubleHorizontalPage(update.value)
+                }
+            }
+            is ConfigUpdate.ProgressBarBehavior -> {
+                scope.launch {
+                    readSettingsRepository.setProgressBarBehavior(update.value)
+                }
+            }
+            is ConfigUpdate.MouseWheelPage -> {
+                scope.launch {
+                    readSettingsRepository.setMouseWheelPage(update.value)
+                }
+            }
+            is ConfigUpdate.VolumeKeyPage -> {
+                scope.launch {
+                    readSettingsRepository.setVolumeKeyPage(update.value)
+                }
+            }
+            is ConfigUpdate.VolumeKeyPageOnPlay -> {
+                scope.launch {
+                    readSettingsRepository.setVolumeKeyPageOnPlay(update.value)
+                }
+            }
+            is ConfigUpdate.KeyPageOnLongPress -> {
+                scope.launch {
+                    readSettingsRepository.setKeyPageOnLongPress(update.value)
+                }
+            }
+            is ConfigUpdate.SliderVibrator -> {
+                scope.launch {
+                    readSettingsRepository.setSliderVibrator(update.value)
+                }
+            }
+            is ConfigUpdate.SelectVibrator -> {
+                scope.launch {
+                    readSettingsRepository.setSelectVibrator(update.value)
+                }
+            }
+            is ConfigUpdate.AutoChangeSource -> {
+                scope.launch {
+                    readSettingsRepository.setAutoChangeSource(update.value)
+                }
+            }
+            is ConfigUpdate.AutoSuggestDayNight -> {
+                if (update.value) {
+                    host.resetDayNightReminderDismissal()
+                }
+                scope.launch {
+                    readSettingsRepository.setAutoSuggestDayNight(update.value)
+                }
+            }
+            is ConfigUpdate.SelectText -> {
+                scope.launch {
+                    readSettingsRepository.setSelectText(update.value)
+                }
+                host.emitEffect(ReadBookEffect.UpTextSelectAble(update.value))
+            }
+            is ConfigUpdate.NoAnimScrollPage -> {
+                scope.launch {
+                    readSettingsRepository.setNoAnimScrollPage(update.value)
+                }
+                host.emitEffect(ReadBookEffect.UpPageAnim(upRecorder = false))
+            }
+            is ConfigUpdate.OptimizeRender -> {
+                scope.launch {
+                    readSettingsRepository.setOptimizeRender(update.value)
+                }
+            }
+            is ConfigUpdate.ClickImgWay -> {
+                scope.launch {
+                    readSettingsRepository.setClickImgWay(update.value)
+                }
+            }
+            is ConfigUpdate.DisableReturnKey -> {
+                scope.launch {
+                    readSettingsRepository.setDisableReturnKey(update.value)
+                }
+            }
+            is ConfigUpdate.ExpandTextMenu -> {
+                scope.launch {
+                    readSettingsRepository.setExpandTextMenu(update.value)
+                }
+            }
+            is ConfigUpdate.ShowSelectMenuIcon -> {
+                scope.launch {
+                    readSettingsRepository.setShowSelectMenuIcon(update.value)
+                }
+            }
+            is ConfigUpdate.ShowReadTitleAddition -> {
+                scope.launch {
+                    readSettingsRepository.setShowReadTitleAddition(update.value)
+                }
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+
+            // --- Auto read ---
+            is ConfigUpdate.AutoReadSpeed -> {
+                scope.launch {
+                    readSettingsRepository.setAutoReadSpeed(update.value)
+                }
+            }
+
+            // --- Chinese converter ---
+            is ConfigUpdate.ChineseConverterType -> {
+                scope.launch {
+                    readSettingsRepository.setChineseConverterType(update.value)
+                }
+            }
+        }
+
+        // 走了 gateway 的更新由 collectReadStyle 重建快照;只写 DataStore 的更新
+        // 不经 gateway,需在此手工重建——且必须两份一起,
+        // 否则像 ChineseConverterType 这种本就在 sheetConfig 里的项会一直显示旧值。
+        if (styleMutation == null) {
+            host.refreshConfigSnapshots()
+        }
+        if (update.actions.isNotEmpty()) {
+            host.emitEffect(ReadBookEffect.UpdateReadViewConfig(update.actions))
+        }
+    }
+
+    private fun ConfigUpdate.toReadStyleMutation(): ReadStyleMutation? = when (this) {
+        is ConfigUpdate.TextSize -> intMutation(ReadStyleIntKey.TextSize, value)
+        is ConfigUpdate.LetterSpacing -> floatMutation(ReadStyleFloatKey.LetterSpacing, value)
+        is ConfigUpdate.LineSpacing -> intMutation(ReadStyleIntKey.LineSpacing, value)
+        is ConfigUpdate.ParagraphSpacing -> intMutation(ReadStyleIntKey.ParagraphSpacing, value)
+        is ConfigUpdate.ParagraphIndent -> stringMutation(ReadStyleStringKey.ParagraphIndent, value)
+        is ConfigUpdate.TextItalic -> booleanMutation(ReadStyleBooleanKey.TextItalic, value)
+        is ConfigUpdate.TextBold -> intMutation(ReadStyleIntKey.TextBold, value)
+        is ConfigUpdate.TextColor -> colorMutation(ReadStyleColorKey.Text, color)
+        is ConfigUpdate.TextAccentColor -> colorMutation(ReadStyleColorKey.TextAccent, color)
+        is ConfigUpdate.TitleMode -> intMutation(ReadStyleIntKey.TitleMode, value)
+        is ConfigUpdate.TitleBold -> intMutation(ReadStyleIntKey.TitleBold, value)
+        is ConfigUpdate.TitleSegScaling -> floatMutation(ReadStyleFloatKey.TitleSegScaling, value)
+        is ConfigUpdate.TitleLineSpacingExtra ->
+            intMutation(ReadStyleIntKey.TitleLineSpacingExtra, value)
+        is ConfigUpdate.TitleLineSpacingSub ->
+            intMutation(ReadStyleIntKey.TitleLineSpacingSub, value)
+        is ConfigUpdate.TitleSize -> intMutation(ReadStyleIntKey.TitleSize, value)
+        is ConfigUpdate.TitleTopSpacing -> intMutation(ReadStyleIntKey.TitleTopSpacing, value)
+        is ConfigUpdate.TitleBottomSpacing ->
+            intMutation(ReadStyleIntKey.TitleBottomSpacing, value)
+        is ConfigUpdate.TitleColor -> colorMutation(ReadStyleColorKey.Title, color)
+        is ConfigUpdate.TitleColorNight -> colorMutation(ReadStyleColorKey.TitleNight, color)
+        is ConfigUpdate.TitleFont -> stringMutation(ReadStyleStringKey.TitleFont, path)
+        is ConfigUpdate.TitleSegType -> intMutation(ReadStyleIntKey.TitleSegType, value)
+        is ConfigUpdate.TitleSegDistance -> intMutation(ReadStyleIntKey.TitleSegDistance, value)
+        is ConfigUpdate.TitleSegFlag -> stringMutation(ReadStyleStringKey.TitleSegFlag, value)
+        is ConfigUpdate.HeaderMode -> intMutation(ReadStyleIntKey.HeaderMode, value)
+        is ConfigUpdate.FooterMode -> intMutation(ReadStyleIntKey.FooterMode, value)
+        is ConfigUpdate.TipHeaderLeft -> intMutation(ReadStyleIntKey.TipHeaderLeft, value)
+        is ConfigUpdate.TipHeaderMiddle -> intMutation(ReadStyleIntKey.TipHeaderMiddle, value)
+        is ConfigUpdate.TipHeaderRight -> intMutation(ReadStyleIntKey.TipHeaderRight, value)
+        is ConfigUpdate.TipFooterLeft -> intMutation(ReadStyleIntKey.TipFooterLeft, value)
+        is ConfigUpdate.TipFooterMiddle -> intMutation(ReadStyleIntKey.TipFooterMiddle, value)
+        is ConfigUpdate.TipFooterRight -> intMutation(ReadStyleIntKey.TipFooterRight, value)
+        is ConfigUpdate.HeaderFont -> stringMutation(ReadStyleStringKey.HeaderFont, path)
+        is ConfigUpdate.CustomTipHeaderLeft ->
+            stringMutation(ReadStyleStringKey.CustomTipHeaderLeft, value)
+        is ConfigUpdate.CustomTipHeaderMiddle ->
+            stringMutation(ReadStyleStringKey.CustomTipHeaderMiddle, value)
+        is ConfigUpdate.CustomTipHeaderRight ->
+            stringMutation(ReadStyleStringKey.CustomTipHeaderRight, value)
+        is ConfigUpdate.CustomTipFooterLeft ->
+            stringMutation(ReadStyleStringKey.CustomTipFooterLeft, value)
+        is ConfigUpdate.CustomTipFooterMiddle ->
+            stringMutation(ReadStyleStringKey.CustomTipFooterMiddle, value)
+        is ConfigUpdate.CustomTipFooterRight ->
+            stringMutation(ReadStyleStringKey.CustomTipFooterRight, value)
+        is ConfigUpdate.HeaderFontSize -> intMutation(ReadStyleIntKey.HeaderFontSize, value)
+        is ConfigUpdate.FooterFont -> stringMutation(ReadStyleStringKey.FooterFont, path)
+        is ConfigUpdate.FooterFontSize -> intMutation(ReadStyleIntKey.FooterFontSize, value)
+        is ConfigUpdate.ApplyHeaderStyle ->
+            booleanMutation(ReadStyleBooleanKey.ApplyHeaderStyle, value)
+        is ConfigUpdate.TipHeaderColor -> colorMutation(ReadStyleColorKey.TipHeader, color)
+        is ConfigUpdate.TipHeaderColorNight ->
+            colorMutation(ReadStyleColorKey.TipHeaderNight, color)
+        is ConfigUpdate.TipFooterColor -> colorMutation(ReadStyleColorKey.TipFooter, color)
+        is ConfigUpdate.TipFooterColorNight ->
+            colorMutation(ReadStyleColorKey.TipFooterNight, color)
+        is ConfigUpdate.TipDividerColor -> colorMutation(ReadStyleColorKey.TipDivider, color)
+        is ConfigUpdate.PageAnim -> intMutation(ReadStyleIntKey.PageAnim, value)
+        is ConfigUpdate.TextShadow -> booleanMutation(ReadStyleBooleanKey.TextShadow, value)
+        is ConfigUpdate.ShadowRadius -> floatMutation(ReadStyleFloatKey.ShadowRadius, value)
+        is ConfigUpdate.ShadowDx -> floatMutation(ReadStyleFloatKey.ShadowDx, value)
+        is ConfigUpdate.ShadowDy -> floatMutation(ReadStyleFloatKey.ShadowDy, value)
+        is ConfigUpdate.ShadowColor -> colorMutation(ReadStyleColorKey.Shadow, color)
+        is ConfigUpdate.Underline -> booleanMutation(ReadStyleBooleanKey.Underline, value)
+        is ConfigUpdate.DottedLine -> booleanMutation(ReadStyleBooleanKey.DottedLine, value)
+        is ConfigUpdate.UnderlineExtend ->
+            booleanMutation(ReadStyleBooleanKey.UnderlineExtend, value)
+        is ConfigUpdate.UnderlineHeight -> intMutation(ReadStyleIntKey.UnderlineHeight, value)
+        is ConfigUpdate.UnderlinePadding -> intMutation(ReadStyleIntKey.UnderlinePadding, value)
+        is ConfigUpdate.DottedBase -> floatMutation(ReadStyleFloatKey.DottedBase, value)
+        is ConfigUpdate.DottedRatio -> floatMutation(ReadStyleFloatKey.DottedRatio, value)
+        is ConfigUpdate.UnderlineColor -> colorMutation(ReadStyleColorKey.Underline, color)
+        is ConfigUpdate.PaddingTop -> intMutation(ReadStyleIntKey.PaddingTop, value)
+        is ConfigUpdate.PaddingBottom -> intMutation(ReadStyleIntKey.PaddingBottom, value)
+        is ConfigUpdate.PaddingLeft -> intMutation(ReadStyleIntKey.PaddingLeft, value)
+        is ConfigUpdate.PaddingRight -> intMutation(ReadStyleIntKey.PaddingRight, value)
+        is ConfigUpdate.HeaderPaddingTop -> intMutation(ReadStyleIntKey.HeaderPaddingTop, value)
+        is ConfigUpdate.HeaderPaddingBottom ->
+            intMutation(ReadStyleIntKey.HeaderPaddingBottom, value)
+        is ConfigUpdate.HeaderPaddingLeft -> intMutation(ReadStyleIntKey.HeaderPaddingLeft, value)
+        is ConfigUpdate.HeaderPaddingRight ->
+            intMutation(ReadStyleIntKey.HeaderPaddingRight, value)
+        is ConfigUpdate.ShowHeaderLine ->
+            booleanMutation(ReadStyleBooleanKey.ShowHeaderLine, value)
+        is ConfigUpdate.FooterPaddingTop -> intMutation(ReadStyleIntKey.FooterPaddingTop, value)
+        is ConfigUpdate.FooterPaddingBottom ->
+            intMutation(ReadStyleIntKey.FooterPaddingBottom, value)
+        is ConfigUpdate.FooterPaddingLeft -> intMutation(ReadStyleIntKey.FooterPaddingLeft, value)
+        is ConfigUpdate.FooterPaddingRight ->
+            intMutation(ReadStyleIntKey.FooterPaddingRight, value)
+        is ConfigUpdate.ShowFooterLine ->
+            booleanMutation(ReadStyleBooleanKey.ShowFooterLine, value)
+        is ConfigUpdate.BgStr -> stringMutation(ReadStyleStringKey.BgStr, value)
+        is ConfigUpdate.BgStrNight -> stringMutation(ReadStyleStringKey.BgStrNight, value)
+        is ConfigUpdate.BgStrEInk -> stringMutation(ReadStyleStringKey.BgStrEInk, value)
+        is ConfigUpdate.BgType -> intMutation(ReadStyleIntKey.BgType, value)
+        is ConfigUpdate.BgTypeNight -> intMutation(ReadStyleIntKey.BgTypeNight, value)
+        is ConfigUpdate.BgTypeEInk -> intMutation(ReadStyleIntKey.BgTypeEInk, value)
+        is ConfigUpdate.BgAlpha -> intMutation(ReadStyleIntKey.BgAlpha, value)
+        is ConfigUpdate.StatusIconDark ->
+            booleanMutation(ReadStyleBooleanKey.StatusIconDark, value)
+        is ConfigUpdate.StyleName -> stringMutation(ReadStyleStringKey.StyleName, value)
+        else -> null
+    }
+
+}
+
+internal fun intMutation(key: ReadStyleIntKey, value: Int) =
+    ReadStyleMutation.IntValue(key, value)
+
+internal fun floatMutation(key: ReadStyleFloatKey, value: Float) =
+    ReadStyleMutation.FloatValue(key, value)
+
+internal fun booleanMutation(key: ReadStyleBooleanKey, value: Boolean) =
+    ReadStyleMutation.BooleanValue(key, value)
+
+internal fun stringMutation(key: ReadStyleStringKey, value: String) =
+    ReadStyleMutation.StringValue(key, value)
+
+internal fun colorMutation(key: ReadStyleColorKey, value: Int) =
+    ReadStyleMutation.ColorValue(key, value)

+ 18 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadContentEditContract.kt

@@ -0,0 +1,18 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+
+/**
+ * 正文编辑域的状态契约,由 [ReadContentEditDelegate] 独立持有,
+ * 不再以 6 个平铺字段挂在 [ReadBookUiState] 上。
+ * sheet 的开合仍由 [ReadBookUiState.activeSheet] 单一持有。
+ */
+@Stable
+data class ContentEditUiState(
+    val loading: Boolean = false,
+    val text: String = "",
+    val title: String = "",
+    val cursorOffset: Int = 0,
+    val isLocalTxt: Boolean = false,
+    val saveToSource: Boolean = false,
+)

+ 195 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadContentEditDelegate.kt

@@ -0,0 +1,195 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isLocalTxt
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.ReadBook
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.ui.book.read.page.entities.TextPage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * 阅读页正文编辑域:打开编辑弹层、载入当前章正文、保存、还原。
+ *
+ * 自持 [ContentEditUiState];章节读取走 [Host](理由同 [ReadAiDelegate]——
+ * 不让 DAO 直连从 `legacyDaoInjectionBaseline` 洗进宽松的 `legacyUiDaoAccessBaseline`)。
+ *
+ * `execute {}` 是 `BaseViewModel` 的成员,这里换成它的实现体
+ * `Coroutine.async(scope, Dispatchers.IO)`,默认参数一致,语义不变。
+ */
+class ReadContentEditDelegate(
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val readSettingsRepository: ReadSettingsRepository,
+) {
+
+    interface Host {
+        fun setActiveSheet(sheet: ReadBookSheet?)
+
+        suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
+    }
+
+    private val _uiState = MutableStateFlow(ContentEditUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private var pendingCursorOffset: Int? = null
+    private var pendingAnchor: String? = null
+
+    fun open() {
+        pendingCursorOffset = currentOffset()
+        pendingAnchor = currentAnchor()
+        host.setActiveSheet(ReadBookSheet.ContentEdit)
+    }
+
+    /** 关闭弹层时清空正文缓冲,避免下次开弹层闪上一章内容。 */
+    fun onSheetDismissed() {
+        _uiState.update {
+            it.copy(
+                text = "",
+                title = "",
+                cursorOffset = 0,
+                loading = false,
+                saveToSource = false,
+            )
+        }
+    }
+
+    fun setText(text: String) {
+        _uiState.update { it.copy(text = text) }
+    }
+
+    fun setSaveToSource(value: Boolean) {
+        _uiState.update { it.copy(saveToSource = value) }
+    }
+
+    fun load() {
+        _uiState.update { it.copy(loading = true, text = "") }
+        Coroutine.async(scope, Dispatchers.IO) {
+            val book = ReadBook.book ?: return@async
+            val chapter = host.findChapter(book.bookUrl, ReadBook.durChapterIndex)
+                ?: return@async
+            val title = chapter.getDisplayTitle(
+                chineseConverterType = readSettingsRepository.currentSettings.chineseConverterType
+            )
+            val contentProcessor = ContentProcessor.get(book.name, book.origin)
+            val rawContent = BookHelp.getContent(book, chapter) ?: return@async
+            val text = contentProcessor.getContent(book, chapter, rawContent, includeTitle = false)
+                .toString()
+            val cursorOffset = resolveCursorOffset(text)
+            _uiState.update {
+                it.copy(
+                    text = text,
+                    title = title,
+                    cursorOffset = cursorOffset,
+                    isLocalTxt = book.isLocalTxt,
+                )
+            }
+        }.onFinally {
+            _uiState.update { it.copy(loading = false) }
+        }
+    }
+
+    fun save(content: String, saveToSource: Boolean) {
+        Coroutine.async(scope, Dispatchers.IO) {
+            val book = ReadBook.book ?: return@async
+            val chapter = host.findChapter(book.bookUrl, ReadBook.durChapterIndex)
+                ?: return@async
+            BookHelp.saveText(book, chapter, content, saveToSource)
+            ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
+        }
+    }
+
+    fun reset() {
+        _uiState.update { it.copy(loading = true) }
+        Coroutine.async(scope, Dispatchers.IO) {
+            val book = ReadBook.book ?: return@async
+            val chapter = host.findChapter(book.bookUrl, ReadBook.durChapterIndex)
+                ?: return@async
+            BookHelp.delContent(book, chapter)
+            if (!book.isLocal) {
+                ReadBook.bookSource?.let { bookSource ->
+                    WebBook.getContentAwait(bookSource, book, chapter)
+                }
+            }
+            val contentProcessor = ContentProcessor.get(book.name, book.origin)
+            val rawContent = BookHelp.getContent(book, chapter)
+            val text = if (rawContent != null) {
+                contentProcessor.getContent(book, chapter, rawContent, includeTitle = false)
+                    .toString()
+            } else {
+                ""
+            }
+            val cursorOffset = resolveCursorOffset(text)
+            _uiState.update {
+                it.copy(
+                    text = text,
+                    cursorOffset = cursorOffset,
+                    loading = false,
+                )
+            }
+            ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
+        }.onError {
+            _uiState.update { it.copy(loading = false) }
+        }
+    }
+
+    // --- 光标定位:优先用打开弹层那一刻的可见首行,其次用锚点文本 ---
+
+    private fun currentPage(): TextPage? {
+        return ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex)
+    }
+
+    private fun currentOffset(): Int {
+        val page = currentPage()
+        return page?.lines
+            ?.firstOrNull { !it.isTitle && it.text.isNotBlank() }
+            ?.chapterPosition
+            ?: page?.lines
+                ?.firstOrNull { !it.isTitle }
+                ?.chapterPosition
+            ?: ReadBook.durChapterPos
+    }
+
+    private fun currentAnchor(): String? {
+        return currentPage()
+            ?.lines
+            ?.firstOrNull { !it.isTitle && it.text.isNotBlank() }
+            ?.text
+            ?.trim()
+            ?.takeIf { it.isNotEmpty() }
+    }
+
+    private fun resolveCursorOffset(text: String): Int {
+        if (text.isEmpty()) {
+            clearPendingLocation()
+            return 0
+        }
+        val preferred = (pendingCursorOffset ?: currentOffset())
+            .coerceIn(0, text.length)
+        val anchor = pendingAnchor ?: currentAnchor()
+        clearPendingLocation()
+        if (anchor.isNullOrBlank()) {
+            return preferred
+        }
+        val startIndex = (preferred - 200).coerceAtLeast(0)
+        val nearIndex = text.indexOf(anchor, startIndex = startIndex)
+        if (nearIndex >= 0) {
+            return nearIndex
+        }
+        val anyIndex = text.indexOf(anchor)
+        return if (anyIndex >= 0) anyIndex else preferred
+    }
+
+    private fun clearPendingLocation() {
+        pendingCursorOffset = null
+        pendingAnchor = null
+    }
+}

+ 140 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt

@@ -0,0 +1,140 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.domain.gateway.BookContentProcessGateway
+import io.legado.app.domain.model.TextProcessAction
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * 正文处理(划词改写落库项)域(R2.2 续批)。
+ *
+ * 自持 [ContentProcessConfigUiState],不再让每次加载/开关/删除都 copy 整个
+ * [ReadBookUiState]。章节重载是本域和 AI 域共用的动作,故走 [Host]。
+ */
+class ReadContentProcessDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val bookContentProcessGateway: BookContentProcessGateway,
+) {
+
+    interface Host {
+        fun showToast(message: String)
+    }
+
+    private val _uiState = MutableStateFlow(ContentProcessConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    fun load() {
+        val book = ReadBook.book ?: return
+        val chapterIndex = ReadBook.durChapterIndex
+        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.getForChapter(book.bookUrl, chapterIndex)
+                    .mapNotNull { it.toContentProcessItemUi() }
+                    .toImmutableList()
+            }.onSuccess { items ->
+                _uiState.update {
+                    it.copy(isLoading = false, items = items, errorMessage = null)
+                }
+            }.onFailure { error ->
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        errorMessage = error.localizedMessage
+                            ?: context.getString(R.string.error),
+                    )
+                }
+            }
+        }
+    }
+
+    fun toggle(id: String, enabled: Boolean) {
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.setEnabled(id, enabled)
+            }.onSuccess {
+                reloadCurrentChapter()
+                load()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    fun requestDelete(item: ContentProcessItemUi) {
+        _uiState.update { it.copy(deleteItem = item) }
+    }
+
+    fun dismissDelete() {
+        _uiState.update { it.copy(deleteItem = null) }
+    }
+
+    fun confirmDelete() {
+        val item = _uiState.value.deleteItem ?: return
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.delete(item.id)
+            }.onSuccess {
+                _uiState.update { it.copy(deleteItem = null) }
+                reloadCurrentChapter()
+                load()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    /** 弹层关闭时清掉中间态。 */
+    fun onSheetDismissed() {
+        _uiState.value = ContentProcessConfigUiState()
+    }
+
+    /**
+     * 正文处理项变化后重载当前章。AI 域改写落库后也走这里(经 VM 的 Host 转发),
+     * 故带上 bookUrl / chapterIndex 的守卫:期间用户可能已经翻走。
+     */
+    fun reloadCurrentChapter(
+        bookUrl: String? = null,
+        chapterIndex: Int = ReadBook.durChapterIndex,
+    ) {
+        val book = ReadBook.book ?: return
+        if (bookUrl != null && book.bookUrl != bookUrl) return
+        if (ReadBook.durChapterIndex != chapterIndex) return
+        ReadBook.clearTextChapter()
+        for (index in chapterIndex - 1..chapterIndex + 1) {
+            ReadBook.removeLoading(index)
+        }
+        ReadBook.loadContent(resetPageOffset = false)
+    }
+
+    private fun BookContentProcess.toContentProcessItemUi(): ContentProcessItemUi? {
+        val anchor = GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+            ?: return null
+        val action = GSON.fromJsonObject<TextProcessAction>(actionJson).getOrNull()
+            ?: return null
+        return ContentProcessItemUi(
+            id = id,
+            kind = kind,
+            actionType = action.type,
+            enabled = enabled && status == BookContentProcess.STATUS_ACTIVE,
+            chapterIndex = chapterIndex ?: anchor.chapterIndex,
+            selectedText = anchor.selectedText,
+            replacementText = action.replacement ?: action.text.orEmpty(),
+            createdAt = createdAt,
+        )
+    }
+}

+ 20 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleContract.kt

@@ -0,0 +1,20 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * 高亮规则域的状态契约,由 [ReadHighlightRuleDelegate] 独立持有,
+ * 不再挂在 [ReadBookUiState] 上。sheet 的开合仍由 [ReadBookUiState.activeSheet] 单一持有。
+ */
+@Stable
+data class HighlightRuleConfigUiState(
+    val rules: ImmutableList<HighlightRule> = persistentListOf(),
+    val editingRule: HighlightRule? = null,
+    val showNewRule: Boolean = false,
+    val deleteRule: HighlightRule? = null,
+    val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
+)

+ 351 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt

@@ -0,0 +1,351 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import io.legado.app.R
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppLog
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.data.repository.UploadRepository
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.http.decompressed
+import io.legado.app.help.http.newCallResponseBody
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.text
+import io.legado.app.ui.book.read.page.provider.TextChapterLayout
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
+import io.legado.app.ui.widget.components.importComponents.ImportStatus
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isJsonObject
+import io.legado.app.utils.sendToClip
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * 阅读页高亮规则域:增删改排序、导入(文本 / URL / 文件)、导出(文件 / 上传取链)。
+ *
+ * 自持 [HighlightRuleConfigUiState],只通过 [Host] 反向触达 Toast 与「规则变了,重排正文」。
+ *
+ * `execute {}` 是 `BaseViewModel` 的成员,delegate 里改用它的实现体
+ * `Coroutine.async(scope, Dispatchers.IO)`——默认参数(start / executeContext / semaphore)
+ * 与 `execute {}` 完全一致,语义不变。
+ */
+class ReadHighlightRuleDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val highlightRuleRepository: HighlightRuleRepository,
+    private val uploadRepository: UploadRepository,
+) {
+
+    interface Host {
+        fun showToast(message: String)
+
+        /** 规则集变了:让正文重新走高亮排版。 */
+        fun notifyRulesChanged()
+    }
+
+    private val _uiState = MutableStateFlow(HighlightRuleConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    fun load() {
+        val configName = ReadBookConfig.durConfig.name
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+                importState = BaseImportUiState.Idle,
+            )
+        }
+    }
+
+    /** 关闭弹层时清掉编辑/删除/导入的中间态,规则列表保留。 */
+    fun onSheetDismissed() {
+        _uiState.update {
+            it.copy(
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+                importState = BaseImportUiState.Idle,
+            )
+        }
+    }
+
+    // --- 增删改排序 ---
+
+    fun startAddRule() {
+        _uiState.update { it.copy(showNewRule = true) }
+    }
+
+    fun startEditRule(rule: HighlightRule) {
+        _uiState.update { it.copy(editingRule = rule) }
+    }
+
+    fun dismissRuleEdit() {
+        _uiState.update { it.copy(editingRule = null, showNewRule = false) }
+    }
+
+    fun toggleRule(rule: HighlightRule, enabled: Boolean) {
+        val rules = _uiState.value.rules.map {
+            if (it.id == rule.id) it.copy(enabled = enabled) else it
+        }
+        saveRules(rules)
+    }
+
+    fun saveRule(rule: HighlightRule) {
+        val currentRules = _uiState.value.rules
+        val updatedRules = if (currentRules.any { it.id == rule.id }) {
+            currentRules.map { if (it.id == rule.id) rule else it }
+        } else {
+            currentRules + rule
+        }
+        saveRules(updatedRules)
+    }
+
+    fun requestDeleteRule(rule: HighlightRule) {
+        _uiState.update { it.copy(deleteRule = rule) }
+    }
+
+    fun dismissDeleteRule() {
+        _uiState.update { it.copy(deleteRule = null) }
+    }
+
+    fun deletePendingRule() {
+        val rule = _uiState.value.deleteRule ?: return
+        val configName = ReadBookConfig.durConfig.name
+        highlightRuleRepository.delete(rule)
+        TextChapterLayout.invalidateRegexCache()
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                deleteRule = null,
+            )
+        }
+        host.notifyRulesChanged()
+    }
+
+    fun moveRule(from: Int, to: Int) {
+        val rules = _uiState.value.rules
+        if (from !in rules.indices || to !in rules.indices) return
+        val reordered = rules.toMutableList().apply {
+            add(to, removeAt(from))
+        }
+        _uiState.update { it.copy(rules = reordered.toImmutableList()) }
+    }
+
+    fun saveRuleOrder() {
+        saveRules(_uiState.value.rules)
+    }
+
+    private fun saveRules(rules: List<HighlightRule>) {
+        val configName = ReadBookConfig.durConfig.name
+        val sanitizedRules = rules.map(highlightRuleRepository::sanitizeRule)
+        highlightRuleRepository.save(configName, sanitizedRules)
+        TextChapterLayout.invalidateRegexCache()
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+            )
+        }
+        host.notifyRulesChanged()
+    }
+
+    // --- 导入 ---
+
+    fun importSource(text: String) {
+        _uiState.update { it.copy(importState = BaseImportUiState.Loading) }
+        Coroutine.async(scope, Dispatchers.IO) {
+            val importedRules = importSourceAwait(text.trim())
+                .map(highlightRuleRepository::sanitizeRule)
+            if (importedRules.isEmpty()) {
+                throw NoStackTraceException(context.getString(R.string.wrong_format))
+            }
+            val oldRules = highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+                .associateBy { it.id }
+            BaseImportUiState.Success(
+                source = text,
+                items = importedRules.map { rule ->
+                    val oldRule = oldRules[rule.id]
+                    val status = when {
+                        oldRule == null -> ImportStatus.New
+                        oldRule != rule -> ImportStatus.Update
+                        else -> ImportStatus.Existing
+                    }
+                    ImportItemWrapper(
+                        data = rule,
+                        oldData = oldRule,
+                        status = status,
+                        isSelected = status != ImportStatus.Existing,
+                    )
+                }
+            )
+        }.onSuccess { importState ->
+            _uiState.update { it.copy(importState = importState) }
+        }.onError {
+            AppLog.put("导入高亮规则失败\n${it.localizedMessage}", it, true)
+            _uiState.update { state ->
+                state.copy(
+                    importState = BaseImportUiState.Error(
+                        it.localizedMessage ?: context.getString(R.string.wrong_format)
+                    )
+                )
+            }
+        }
+    }
+
+    private suspend fun importSourceAwait(text: String): List<HighlightRule> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<HighlightRule>(text).getOrThrow()
+            text.isJsonObject() -> listOf(
+                GSON.fromJsonObject<HighlightRule>(text).getOrThrow()
+            )
+            text.isAbsUrl() -> {
+                val body = okHttpClient.newCallResponseBody {
+                    if (text.endsWith("#requestWithoutUA")) {
+                        url(text.substringBeforeLast("#requestWithoutUA"))
+                        header(AppConst.UA_NAME, "null")
+                    } else {
+                        url(text)
+                    }
+                }.decompressed().text()
+                importSourceAwait(body)
+            }
+            else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
+        }
+    }
+
+    fun importFile(uri: Uri) {
+        Coroutine.async<String?>(scope, Dispatchers.IO) {
+            context.contentResolver.openInputStream(uri)?.use {
+                it.reader().readText()
+            }
+        }.onSuccess { text ->
+            if (text.isNullOrBlank()) {
+                _uiState.update { state ->
+                    state.copy(
+                        importState = BaseImportUiState.Error(
+                            context.getString(R.string.wrong_format)
+                        )
+                    )
+                }
+            } else {
+                importSource(text)
+            }
+        }.onError {
+            _uiState.update { state ->
+                state.copy(
+                    importState = BaseImportUiState.Error(
+                        it.localizedMessage ?: context.getString(R.string.wrong_format)
+                    )
+                )
+            }
+        }
+    }
+
+    fun cancelImport() {
+        _uiState.update { it.copy(importState = BaseImportUiState.Idle) }
+    }
+
+    fun toggleImportSelection(index: Int) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        val item = items[index]
+        items[index] = item.copy(isSelected = !item.isSelected)
+        _uiState.update { it.copy(importState = importState.copy(items = items)) }
+    }
+
+    fun toggleImportAll(isSelected: Boolean) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        _uiState.update {
+            it.copy(
+                importState = importState.copy(
+                    items = importState.items.map { item ->
+                        item.copy(isSelected = isSelected)
+                    }
+                )
+            )
+        }
+    }
+
+    fun updateImportItem(index: Int, rule: HighlightRule) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        items[index] = items[index].copy(data = rule)
+        _uiState.update {
+            it.copy(
+                importState = importState.copy(
+                    items = items,
+                    version = importState.version + 1,
+                )
+            )
+        }
+    }
+
+    fun saveImported() {
+        val state = _uiState.value
+        val importState = state.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        val importedRules = importState.items
+            .filter { it.isSelected }
+            .map { highlightRuleRepository.sanitizeRule(it.data) }
+        if (importedRules.isEmpty()) return
+        val importedById = importedRules.associateBy { it.id }
+        val mergedRules = state.rules.map { importedById[it.id] ?: it } +
+                importedRules.filter { imported -> state.rules.none { it.id == imported.id } }
+        saveRules(mergedRules)
+        cancelImport()
+    }
+
+    // --- 导出 ---
+
+    fun exportToFile(uri: Uri) {
+        val rules = _uiState.value.rules
+        Coroutine.async(scope, Dispatchers.IO) {
+            context.contentResolver.openOutputStream(uri)?.use { output ->
+                output.bufferedWriter().use { writer ->
+                    writer.write(GSON.toJson(rules))
+                }
+            } ?: throw NoStackTraceException(context.getString(R.string.error))
+        }.onSuccess {
+            host.showToast(context.getString(R.string.export_success))
+        }.onError {
+            host.showToast(it.localizedMessage ?: context.getString(R.string.error))
+        }
+    }
+
+    fun exportAsUrl() {
+        val rules = _uiState.value.rules
+        Coroutine.async(scope, Dispatchers.IO) {
+            uploadRepository.upload(
+                fileName = HighlightRuleRepository.backupFileName,
+                file = GSON.toJson(rules),
+                contentType = "application/json",
+            )
+        }.onSuccess { url ->
+            context.sendToClip(url)
+            host.showToast(context.getString(R.string.copy_url))
+        }
+    }
+}

+ 144 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadSearchResultLocator.kt

@@ -0,0 +1,144 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextPage
+import io.legado.app.ui.book.searchContent.SearchResult
+
+/**
+ * 把一条 [SearchResult] 定位到当前章排版结果里的 (页, 行, 列)。
+ *
+ * 这段是纯函数——只吃 (TextChapter, SearchResult, query),不碰任何 UI 状态,
+ * 所以从 `ReadBookViewModel` 摘出来独立成对象,[findMatch] 可以直接写单测覆盖。
+ * `ReadBookViewModel.searchResultPositions` 保留为转发入口,供 `ReadBookController` 调用。
+ */
+internal object ReadSearchResultLocator {
+
+    /** @return `[pageIndex, lineIndex, charIndex, pageSpan, endLineIndex, endCharIndex]`;定位失败首位为 -1。 */
+    fun positions(
+        textChapter: TextChapter,
+        searchResult: SearchResult,
+        query: String,
+    ): Array<Int> {
+        val pages = textChapter.pages
+        val content = textChapter.getContent()
+        if (pages.isEmpty()) return NOT_FOUND
+
+        val match = findMatch(content, searchResult, query) ?: return NOT_FOUND
+        val contentPosition = match.first
+        val queryLength = match.second
+        if (contentPosition < 0 || queryLength <= 0) {
+            return NOT_FOUND
+        }
+
+        val start = findTextPoint(pages, contentPosition, preferPreviousLine = false)
+            ?: return NOT_FOUND
+        val end = findTextPoint(
+            pages,
+            contentPosition + queryLength - 1,
+            preferPreviousLine = true
+        ) ?: start
+        return arrayOf(
+            start.pageIndex,
+            start.lineIndex,
+            start.charIndex,
+            end.pageIndex - start.pageIndex,
+            end.lineIndex,
+            end.charIndex
+        )
+    }
+
+    /**
+     * 在章节正文里找到这条结果对应的 `位置 to 长度`。
+     *
+     * 先信 [SearchResult.queryIndexInChapter] 这个直达下标(校验后才用),校验不过再退回
+     * 「数到第 [SearchResult.resultCountWithinChapter] 次出现」的扫描。
+     */
+    internal fun findMatch(
+        content: String,
+        searchResult: SearchResult,
+        query: String,
+    ): Pair<Int, Int>? {
+        if (query.isEmpty()) return null
+        val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
+        val directIndex = searchResult.queryIndexInChapter
+        if (directIndex >= 0 && directIndex + directLength <= content.length) {
+            val directMatch = if (searchResult.isRegex) {
+                runCatching {
+                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
+                }.getOrDefault(false)
+            } else {
+                content.regionMatches(
+                    directIndex,
+                    query,
+                    0,
+                    query.length,
+                    ignoreCase = false
+                )
+            }
+            if (directMatch) {
+                return directIndex to directLength
+            }
+        }
+        if (searchResult.isRegex) {
+            return runCatching {
+                Regex(query).findAll(content)
+                    .drop(searchResult.resultCountWithinChapter)
+                    .firstOrNull()
+                    ?.let { it.range.first to it.value.length }
+            }.getOrNull()
+        }
+
+        var count = 0
+        var index = content.indexOf(query)
+        while (count != searchResult.resultCountWithinChapter && index >= 0) {
+            index = content.indexOf(query, index + query.length)
+            count += 1
+        }
+        return index.takeIf { it >= 0 }?.let { it to query.length }
+    }
+
+    private fun findTextPoint(
+        pages: List<TextPage>,
+        contentPosition: Int,
+        preferPreviousLine: Boolean,
+    ): SearchTextPoint? {
+        var fallback: SearchTextPoint? = null
+        pages.forEachIndexed { pageIndex, page ->
+            page.lines.forEachIndexed { lineIndex, line ->
+                if (line.columns.isEmpty()) return@forEachIndexed
+                val lineStart = line.chapterPosition
+                val lineEndExclusive = lineStart + line.charSize
+                if (contentPosition in lineStart until lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
+                    )
+                }
+                if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+                if (contentPosition >= lineEndExclusive) {
+                    fallback = SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+            }
+        }
+        return fallback
+    }
+
+    private val NOT_FOUND get() = arrayOf(-1, 0, 0, 0, 0, 0)
+
+    private data class SearchTextPoint(
+        val pageIndex: Int,
+        val lineIndex: Int,
+        val charIndex: Int,
+    )
+}

+ 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
+    }
+}

+ 25 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderFirstFrame.kt

@@ -0,0 +1,25 @@
+package io.legado.app.ui.book.read
+
+import android.os.SystemClock
+import android.util.Log
+import io.legado.app.BuildConfig
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal const val EXTRA_FIRST_FRAME_STARTED_AT_NANOS = "readerFirstFrameStartedAtNanos"
+
+/**
+ * Debug-only 首帧探针:从 intent 传入的启动时刻到正文第一次绘制之间的耗时,
+ * 通过 logcat tag `ReaderFirstFrame` 输出,供 tools 侧脚本采集。
+ */
+internal class ReaderFirstFrameTracker(
+    private val startedAtNanos: Long,
+) {
+    private val reported = AtomicBoolean(false)
+
+    fun report() {
+        if (!BuildConfig.DEBUG) return
+        if (!reported.compareAndSet(false, true)) return
+        val durationMillis = (SystemClock.elapsedRealtimeNanos() - startedAtNanos) / 1_000_000f
+        Log.i("ReaderFirstFrame", "renderer=legacy durationMs=$durationMillis")
+    }
+}

+ 118 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderViewport.kt

@@ -0,0 +1,118 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Immutable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+
+@Immutable
+enum class ReaderLayoutMode {
+    PAGED,
+    SCROLL,
+    DOUBLE_PAGE,
+}
+
+@Immutable
+data class ReaderPadding(
+    val left: Int = 0,
+    val top: Int = 0,
+    val right: Int = 0,
+    val bottom: Int = 0,
+) {
+    val horizontal: Int get() = left + right
+    val vertical: Int get() = top + bottom
+}
+
+@Immutable
+data class ReaderViewport(
+    val widthPx: Int,
+    val heightPx: Int,
+    val density: Float,
+    val contentPadding: ReaderPadding = ReaderPadding(),
+    val mode: ReaderLayoutMode = ReaderLayoutMode.PAGED,
+) {
+    val contentWidthPx: Int get() = (widthPx - contentPadding.horizontal).coerceAtLeast(0)
+    val contentHeightPx: Int get() = (heightPx - contentPadding.vertical).coerceAtLeast(0)
+    val isReady: Boolean get() = contentWidthPx > 0 && contentHeightPx > 0 && density > 0f
+}
+
+@Immutable
+data class ReaderLayoutResult(
+    val revision: Long,
+    val pageIndex: Int,
+    val viewport: ReaderViewport,
+)
+
+interface ReaderLayoutController {
+    val viewport: StateFlow<ReaderViewport?>
+    val layoutResults: Flow<ReaderLayoutResult>
+
+    fun updateViewport(viewport: ReaderViewport)
+
+    fun requestRelayout()
+
+    suspend fun awaitViewport(timeoutMillis: Long = VIEWPORT_WAIT_TIMEOUT_MILLIS): ReaderViewport?
+
+    companion object {
+        const val VIEWPORT_WAIT_TIMEOUT_MILLIS = 2_000L
+    }
+}
+
+/**
+ * Track C · C4 的尺寸输入接缝。分页仍由 ChapterProvider 完成;本类只负责把
+ * View/Compose 的测量结果统一成正文尺寸,并暴露排版完成 revision。
+ */
+internal class ReaderLayoutCoordinator(
+    private val updateLayoutSize: (width: Int, height: Int) -> Unit,
+    private val relayoutContent: () -> Unit,
+) : ReaderLayoutController {
+
+    private val _viewport = MutableStateFlow<ReaderViewport?>(null)
+    override val viewport: StateFlow<ReaderViewport?> = _viewport.asStateFlow()
+
+    private val _layoutResults = MutableSharedFlow<ReaderLayoutResult>(extraBufferCapacity = 16)
+    override val layoutResults: Flow<ReaderLayoutResult> = _layoutResults.asSharedFlow()
+
+    private var appliedContentWidth = 0
+    private var appliedContentHeight = 0
+    private var layoutRevision = 0L
+
+    override fun updateViewport(viewport: ReaderViewport) {
+        if (!viewport.isReady) return
+        _viewport.value = viewport
+        if (
+            viewport.contentWidthPx == appliedContentWidth &&
+            viewport.contentHeightPx == appliedContentHeight
+        ) return
+        appliedContentWidth = viewport.contentWidthPx
+        appliedContentHeight = viewport.contentHeightPx
+        updateLayoutSize(appliedContentWidth, appliedContentHeight)
+    }
+
+    override fun requestRelayout() {
+        relayoutContent()
+    }
+
+    override suspend fun awaitViewport(timeoutMillis: Long): ReaderViewport? {
+        return withTimeoutOrNull(timeoutMillis) {
+            viewport.filter { it?.isReady == true }.first()
+        }
+    }
+
+    fun publishPageLayout(pageIndex: Int) {
+        val currentViewport = _viewport.value ?: return
+        _layoutResults.tryEmit(
+            ReaderLayoutResult(
+                revision = ++layoutRevision,
+                pageIndex = pageIndex,
+                viewport = currentViewport,
+            )
+        )
+    }
+}

+ 14 - 2
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -12,6 +12,8 @@ import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.ui.book.read.page.delegate.PageDelegate
+import io.legado.app.ui.book.read.ReaderLayoutController
+import io.legado.app.ui.book.read.ReaderViewport
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -51,9 +53,12 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     private var callBack: CallBack? = null
     private val requireCallBack: CallBack
         get() = callBack ?: activity as CallBack
-    private val visibleRect = ChapterProvider.visibleRect
+    // 每次读取——ChapterProvider 的排版度量是不可变快照,重新排版会换成新的 RectF 实例,
+    // 捕获引用会永远停在旧尺寸上。
+    private val visibleRect get() = ChapterProvider.visibleRect
     val selectStart = TextPos(0, -1, -1)
     private val selectEnd = TextPos(0, -1, -1)
+    val selectEndPosition: TextPos get() = selectEnd
     var textPage: TextPage = TextPage()
         private set
     var isMainView = false
@@ -98,7 +103,13 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         if (!isMainView || MainNavigator.backNavigationInProgress) return
-        ChapterProvider.upViewSize(w, h)
+        requireCallBack.readerLayoutController.updateViewport(
+            ReaderViewport(
+                widthPx = w,
+                heightPx = h,
+                density = resources.displayMetrics.density,
+            )
+        )
         textPage.format()
     }
 
@@ -773,6 +784,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     }
 
     interface CallBack {
+        val readerLayoutController: ReaderLayoutController
         val headerHeight: Int
         val imgBgPaddingStart: Int
         val pageFactory: TextPageFactory

+ 11 - 8
app/src/main/java/io/legado/app/ui/book/read/page/CustomTipPageRemaining.kt

@@ -1,16 +1,19 @@
 package io.legado.app.ui.book.read.page
 
+/**
+ * 自定义页眉/页脚模板里 `{PageRemaining}` 的取值。
+ *
+ * **刻意只出数字,剩 0 页也不换成「本章完」**:模板是用户自己拼的,占位符两侧往往还有
+ * 自己写的文案(「剩 {PageRemaining} 页」),中途把数字换成一句话会让整行读不通。
+ * 要「本章完」这种语义,用户在模板里自己写即可。
+ *
+ * 唯一的非数字取值是 `-`:章节还没排完且页数未知,此时余量无从算起。
+ */
 internal fun formatCustomTipPageRemaining(
     pageSize: Int,
     pageIndex: Int,
     isChapterCompleted: Boolean,
-    chapterCompleteText: String,
 ): String {
-    val pageRemaining = (pageSize - pageIndex - 1).coerceAtLeast(0)
-    return when {
-        isChapterCompleted && pageRemaining == 0 -> chapterCompleteText
-        isChapterCompleted -> pageRemaining.toString()
-        pageSize <= 0 -> "-"
-        else -> pageRemaining.toString()
-    }
+    if (!isChapterCompleted && pageSize <= 0) return "-"
+    return (pageSize - pageIndex - 1).coerceAtLeast(0).toString()
 }

+ 130 - 150
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -10,22 +10,22 @@ import android.view.LayoutInflater
 import android.widget.FrameLayout
 import androidx.core.content.ContextCompat
 import androidx.core.graphics.drawable.toDrawable
-import androidx.core.net.toUri
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.isGone
 import io.legado.app.R
 import io.legado.app.constant.AppConst.timeFormat
+import io.legado.app.constant.ReadTipType
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.databinding.ViewBookPageBinding
 import io.legado.app.help.config.CustomTipPlaceholder
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.sheet.CustomTipTarget
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.widget.BatteryView
@@ -35,12 +35,10 @@ import io.legado.app.utils.applyStatusBarPadding
 import io.legado.app.utils.canvasrecorder.CanvasRecorder
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
-import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.navigationBarHeight
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.statusBarHeight
 import splitties.views.backgroundColor
-import java.io.File
 import java.util.Date
 import io.legado.app.utils.screenshot as viewScreenshot
 
@@ -91,6 +89,14 @@ class PageView(
             return binding.vwRoot.paddingStart
         }
 
+    val contentOffsetX: Int get() = binding.contentTextView.left
+
+    val contentOffsetY: Int get() = binding.contentTextView.top
+
+    val contentWidth: Int get() = binding.contentTextView.width
+
+    val contentHeight: Int get() = binding.contentTextView.height
+
     init {
         callBack?.let { binding.contentTextView.setCallBack(it) }
         upStyle()
@@ -123,67 +129,45 @@ class PageView(
     }
 
     fun upStyle() = binding.run {
-        upTipStyle()
-        ReadBookConfig.let {
-            val textColor = it.textColor
-            val headerColor = with(ReadBookConfig) {
-                if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
-            }
-            val footerColor = with(ReadBookConfig) {
-                if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
-            }
-            val tipDividerColor = with(ReadBookConfig) {
-                when (tipDividerColor) {
-                    -1 -> ContextCompat.getColor(context, R.color.divider)
-                    0 -> textColor
-                    else -> tipDividerColor
-                }
-            }
-            tvHeaderLeft.setColor(headerColor)
-            tvHeaderMiddle.setColor(headerColor)
-            tvHeaderRight.setColor(headerColor)
-            tvFooterLeft.setColor(footerColor)
-            tvFooterMiddle.setColor(footerColor)
-            tvFooterRight.setColor(footerColor)
-            vwTopDivider.backgroundColor = tipDividerColor
-            vwBottomDivider.backgroundColor = tipDividerColor
-            upStatusBar()
-            upNavigationBar()
-            upPaddingDisplayCutouts()
-            llHeader.setPadding(
-                it.headerPaddingLeft.dpToPx(),
-                it.headerPaddingTop.dpToPx(),
-                it.headerPaddingRight.dpToPx(),
-                it.headerPaddingBottom.dpToPx()
-            )
-            llFooter.setPadding(
-                it.footerPaddingLeft.dpToPx(),
-                it.footerPaddingTop.dpToPx(),
-                it.footerPaddingRight.dpToPx(),
-                it.footerPaddingBottom.dpToPx()
-            )
-            vwTopDivider.gone(llHeader.isGone || !it.showHeaderLine)
-            vwBottomDivider.gone(llFooter.isGone || !it.showFooterLine)
-        }
+        val style = TipStyleProvider.style
+        upTipStyle(style)
+        applyTipColors(style)
+        upStatusBar()
+        upNavigationBar()
+        upPaddingDisplayCutouts()
+        llHeader.setPadding(
+            style.headerPaddingLeft.dpToPx(),
+            style.headerPaddingTop.dpToPx(),
+            style.headerPaddingRight.dpToPx(),
+            style.headerPaddingBottom.dpToPx()
+        )
+        llFooter.setPadding(
+            style.footerPaddingLeft.dpToPx(),
+            style.footerPaddingTop.dpToPx(),
+            style.footerPaddingRight.dpToPx(),
+            style.footerPaddingBottom.dpToPx()
+        )
+        vwTopDivider.gone(llHeader.isGone || !style.showHeaderLine)
+        vwBottomDivider.gone(llFooter.isGone || !style.showFooterLine)
         upTime()
         upBattery(battery)
     }
 
     /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
     fun upThemeColors() = binding.run {
-        val textColor = ReadBookConfig.textColor
-        val headerColor = with(ReadBookConfig) {
-            if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
-        }
-        val footerColor = with(ReadBookConfig) {
-            if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
-        }
-        val tipDividerColor = with(ReadBookConfig) {
-            when (tipDividerColor) {
-                -1 -> ContextCompat.getColor(context, R.color.divider)
-                0 -> textColor
-                else -> tipDividerColor
-            }
+        applyTipColors(TipStyleProvider.style)
+        contentTextView.invalidate()
+    }
+
+    /** 页眉/页脚文字色与分隔线色:0 表示跟随正文色,-1(分隔线)表示用主题色。 */
+    private fun applyTipColors(style: TipStyleProvider.TipStyle) = binding.run {
+        val textColor = style.textColor
+        val headerColor = if (style.tipHeaderColor == 0) textColor else style.tipHeaderColor
+        val footerColor = if (style.tipFooterColor == 0) textColor else style.tipFooterColor
+        val dividerColor = when (style.tipDividerColor) {
+            -1 -> ContextCompat.getColor(context, R.color.divider)
+            0 -> textColor
+            else -> style.tipDividerColor
         }
         tvHeaderLeft.setColor(headerColor)
         tvHeaderMiddle.setColor(headerColor)
@@ -191,27 +175,8 @@ class PageView(
         tvFooterLeft.setColor(footerColor)
         tvFooterMiddle.setColor(footerColor)
         tvFooterRight.setColor(footerColor)
-        vwTopDivider.backgroundColor = tipDividerColor
-        vwBottomDivider.backgroundColor = tipDividerColor
-        contentTextView.invalidate()
-    }
-
-    private fun loadTypeface(fontPath: String): Typeface? {
-        return runCatching {
-            when {
-                fontPath.isContentScheme() -> {
-                    context.contentResolver
-                        .openFileDescriptor(fontPath.toUri(), "r")
-                        ?.use {
-                            Typeface.Builder(it.fileDescriptor).build()
-                        }
-                }
-                fontPath.isNotEmpty() -> {
-                    Typeface.Builder(File(fontPath)).build()
-                }
-                else -> null
-            }
-        }.getOrNull()
+        vwTopDivider.backgroundColor = dividerColor
+        vwBottomDivider.backgroundColor = dividerColor
     }
 
     /**
@@ -219,11 +184,11 @@ class PageView(
      */
     fun upStatusBar() = with(binding.vwStatusBar) {
         setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom)
-        isGone = ReadBookConfig.hideStatusBar || activity?.isInMultiWindowMode == true
+        isGone = TipStyleProvider.style.hideStatusBar || activity?.isInMultiWindowMode == true
     }
 
     fun upNavigationBar() {
-        binding.vwNavigationBar.isGone = ReadBookConfig.hideNavigationBar
+        binding.vwNavigationBar.isGone = TipStyleProvider.style.hideNavigationBar
     }
 
     fun upPaddingDisplayCutouts() {
@@ -247,27 +212,27 @@ class PageView(
     /**
      * 更新阅读信息
      */
-    private fun upTipStyle() = binding.run {
+    private fun upTipStyle(style: TipStyleProvider.TipStyle) = binding.run {
         tvHeaderLeft.tag = null
         tvHeaderMiddle.tag = null
         tvHeaderRight.tag = null
         tvFooterLeft.tag = null
         tvFooterMiddle.tag = null
         tvFooterRight.tag = null
-        llHeader.isGone = when (ReadBookConfig.headerMode) {
+        llHeader.isGone = when (style.headerMode) {
             1 -> false
             2 -> true
-            else -> !ReadBookConfig.hideStatusBar
+            else -> !style.hideStatusBar
         }
-        llFooter.isGone = when (ReadBookConfig.footerMode) {
+        llFooter.isGone = when (style.footerMode) {
             1 -> true
             else -> false
         }
-        ReadBookConfig.apply {
-            tvHeaderLeft.isGone = tipHeaderLeft == tipNone
-            tvHeaderMiddle.isGone = tipHeaderMiddle == tipNone
-            if (tipHeaderRight == tipNone) {
-                if (tipHeaderMiddle == tipNone && tipHeaderLeft == tipNone) {
+        style.apply {
+            tvHeaderLeft.isGone = tipHeaderLeft == ReadTipType.tipNone
+            tvHeaderMiddle.isGone = tipHeaderMiddle == ReadTipType.tipNone
+            if (tipHeaderRight == ReadTipType.tipNone) {
+                if (tipHeaderMiddle == ReadTipType.tipNone && tipHeaderLeft == ReadTipType.tipNone) {
                     tvHeaderRight.isGone = true
                 } else {
                     tvHeaderRight.isGone = false
@@ -277,10 +242,10 @@ class PageView(
                 tvHeaderRight.isGone = false
                 tvHeaderRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             }
-            tvFooterLeft.isGone = tipFooterLeft == tipNone
-            tvFooterMiddle.isGone = tipFooterMiddle == tipNone
-            if (tipFooterRight == tipNone) {
-                if (tipFooterLeft == tipNone && tipFooterMiddle == tipNone) {
+            tvFooterLeft.isGone = tipFooterLeft == ReadTipType.tipNone
+            tvFooterMiddle.isGone = tipFooterMiddle == ReadTipType.tipNone
+            if (tipFooterRight == ReadTipType.tipNone) {
+                if (tipFooterLeft == ReadTipType.tipNone && tipFooterMiddle == ReadTipType.tipNone) {
                     tvFooterRight.isGone = true
                 } else {
                     tvFooterRight.isGone = false
@@ -291,118 +256,118 @@ class PageView(
                 tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             }
         }
-        val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface
-        val tipTextSize = ReadBookConfig.headerFontSize.toFloat()
-        tvTitle = getTipView(ReadBookConfig.tipChapterTitle)?.apply {
-            tag = ReadBookConfig.tipChapterTitle
+        val tipTypeface = style.tipTypeface ?: ChapterProvider.typeface
+        val tipTextSize = style.tipTextSize
+        tvTitle = getTipView(ReadTipType.tipChapterTitle)?.apply {
+            tag = ReadTipType.tipChapterTitle
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvTitleArrow = getTipView(ReadBookConfig.tipChapterTitleArrow)?.apply {
-            tag = ReadBookConfig.tipChapterTitleArrow
+        tvTitleArrow = getTipView(ReadTipType.tipChapterTitleArrow)?.apply {
+            tag = ReadTipType.tipChapterTitleArrow
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
-        tvTitleArrowClassic = getTipView(ReadBookConfig.tipChapterTitleArrowClassic)?.apply {
-            tag = ReadBookConfig.tipChapterTitleArrowClassic
+        tvTitleArrowClassic = getTipView(ReadTipType.tipChapterTitleArrowClassic)?.apply {
+            tag = ReadTipType.tipChapterTitleArrowClassic
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
-        tvTime = getTipView(ReadBookConfig.tipTime)?.apply {
-            tag = ReadBookConfig.tipTime
+        tvTime = getTipView(ReadTipType.tipTime)?.apply {
+            tag = ReadTipType.tipTime
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvBattery = getTipView(ReadBookConfig.tipBattery)?.apply {
-            tag = ReadBookConfig.tipBattery
+        tvBattery = getTipView(ReadTipType.tipBattery)?.apply {
+            tag = ReadTipType.tipBattery
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.OUTER
         }
-        tvBatteryClassic = getTipView(ReadBookConfig.tipBatteryClassic)?.apply {
-            tag = ReadBookConfig.tipBatteryClassic
+        tvBatteryClassic = getTipView(ReadTipType.tipBatteryClassic)?.apply {
+            tag = ReadTipType.tipBatteryClassic
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
-        tvBatteryInside = getTipView(ReadBookConfig.tipBatteryInside)?.apply {
-            tag = ReadBookConfig.tipBatteryInside
+        tvBatteryInside = getTipView(ReadTipType.tipBatteryInside)?.apply {
+            tag = ReadTipType.tipBatteryInside
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.INNER
         }
-        tvBatteryIcon = getTipView(ReadBookConfig.tipBatteryIcon)?.apply {
-            tag = ReadBookConfig.tipBatteryIcon
+        tvBatteryIcon = getTipView(ReadTipType.tipBatteryIcon)?.apply {
+            tag = ReadTipType.tipBatteryIcon
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ICON
         }
-        tvPage = getTipView(ReadBookConfig.tipPage)?.apply {
-            tag = ReadBookConfig.tipPage
+        tvPage = getTipView(ReadTipType.tipPage)?.apply {
+            tag = ReadTipType.tipPage
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvTotalProgress = getTipView(ReadBookConfig.tipTotalProgress)?.apply {
-            tag = ReadBookConfig.tipTotalProgress
+        tvTotalProgress = getTipView(ReadTipType.tipTotalProgress)?.apply {
+            tag = ReadTipType.tipTotalProgress
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTotalProgress1 = getTipView(ReadBookConfig.tipTotalProgress1)?.apply {
-            tag = ReadBookConfig.tipTotalProgress1
+        tvTotalProgress1 = getTipView(ReadTipType.tipTotalProgress1)?.apply {
+            tag = ReadTipType.tipTotalProgress1
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvPageAndTotal = getTipView(ReadBookConfig.tipPageAndTotal)?.apply {
-            tag = ReadBookConfig.tipPageAndTotal
+        tvPageAndTotal = getTipView(ReadTipType.tipPageAndTotal)?.apply {
+            tag = ReadTipType.tipPageAndTotal
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvWholeBookPage = getTipView(ReadBookConfig.tipWholeBookPage)?.apply {
-            tag = ReadBookConfig.tipWholeBookPage
+        tvWholeBookPage = getTipView(ReadTipType.tipWholeBookPage)?.apply {
+            tag = ReadTipType.tipWholeBookPage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
         tvWholeBookPageAndProgress =
-            getTipView(ReadBookConfig.tipWholeBookPageAndProgress)?.apply {
-                tag = ReadBookConfig.tipWholeBookPageAndProgress
+            getTipView(ReadTipType.tipWholeBookPageAndProgress)?.apply {
+                tag = ReadTipType.tipWholeBookPageAndProgress
                 batteryMode = BatteryView.BatteryMode.NO_BATTERY
                 typeface = tipTypeface
                 textSize = tipTextSize
             }
-        tvBookName = getTipView(ReadBookConfig.tipBookName)?.apply {
-            tag = ReadBookConfig.tipBookName
+        tvBookName = getTipView(ReadTipType.tipBookName)?.apply {
+            tag = ReadTipType.tipBookName
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTimeBattery = getTipView(ReadBookConfig.tipTimeBattery)?.apply {
-            tag = ReadBookConfig.tipTimeBattery
+        tvTimeBattery = getTipView(ReadTipType.tipTimeBattery)?.apply {
+            tag = ReadTipType.tipTimeBattery
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.TIME
         }
-        tvTimeBatteryClassic = getTipView(ReadBookConfig.tipTimeBatteryClassic)?.apply {
-            tag = ReadBookConfig.tipTimeBatteryClassic
+        tvTimeBatteryClassic = getTipView(ReadTipType.tipTimeBatteryClassic)?.apply {
+            tag = ReadTipType.tipTimeBatteryClassic
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
-        tvBatteryP = getTipView(ReadBookConfig.tipBatteryPercentage)?.apply {
-            tag = ReadBookConfig.tipBatteryPercentage
+        tvBatteryP = getTipView(ReadTipType.tipBatteryPercentage)?.apply {
+            tag = ReadTipType.tipBatteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTimeBatteryP = getTipView(ReadBookConfig.tipTimeBatteryPercentage)?.apply {
-            tag = ReadBookConfig.tipTimeBatteryPercentage
+        tvTimeBatteryP = getTipView(ReadTipType.tipTimeBatteryPercentage)?.apply {
+            tag = ReadTipType.tipTimeBatteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
@@ -411,18 +376,18 @@ class PageView(
         val customTypeface = tipTypeface
         val customTextSize = tipTextSize
         for (target in CUSTOM_TIP_TARGETS) {
-            if (target.tipValue != ReadBookConfig.tipCustom) continue
+            if (target.currentTipValue() != ReadTipType.tipCustom) continue
             val view = customTipViewFor(target)
-            view.tag = ReadBookConfig.tipCustom
+            view.tag = ReadTipType.tipCustom
             view.typeface = customTypeface
             view.textSize = customTextSize
             view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
 
         // 当页脚不应用页眉字体样式时,覆盖页脚视图的字体和字号
-        if (!ReadBookConfig.applyHeaderStyle) {
-            val footerTypeface = loadTypeface(ReadBookConfig.footerFont) ?: tipTypeface
-            val footerTextSize = ReadBookConfig.footerFontSize.toFloat()
+        if (!style.applyHeaderStyle) {
+            val footerTypeface = style.footerTypeface ?: tipTypeface
+            val footerTextSize = style.footerTextSize
             listOf(tvFooterLeft, tvFooterMiddle, tvFooterRight).forEach { view ->
                 if (view.tag != null) {
                     view.typeface = footerTypeface
@@ -451,13 +416,14 @@ class PageView(
      * @param tip 信息类型
      */
     private fun getTipView(tip: Int): BatteryView? = binding.run {
+        val style = TipStyleProvider.style
         return when (tip) {
-            ReadBookConfig.tipHeaderLeft -> tvHeaderLeft
-            ReadBookConfig.tipHeaderMiddle -> tvHeaderMiddle
-            ReadBookConfig.tipHeaderRight -> tvHeaderRight
-            ReadBookConfig.tipFooterLeft -> tvFooterLeft
-            ReadBookConfig.tipFooterMiddle -> tvFooterMiddle
-            ReadBookConfig.tipFooterRight -> tvFooterRight
+            style.tipHeaderLeft -> tvHeaderLeft
+            style.tipHeaderMiddle -> tvHeaderMiddle
+            style.tipHeaderRight -> tvHeaderRight
+            style.tipFooterLeft -> tvFooterLeft
+            style.tipFooterMiddle -> tvFooterMiddle
+            style.tipFooterRight -> tvFooterRight
             else -> null
         }
     }
@@ -479,7 +445,7 @@ class PageView(
      * 更新背景透明度
      */
     fun upBgAlpha() {
-        ReadSessionState.background?.alpha = (ReadBookConfig.bgAlpha / 100f * 255).toInt()
+        ReadSessionState.background?.alpha = (TipStyleProvider.style.bgAlpha / 100f * 255).toInt()
         binding.vwRoot.invalidate()
     }
 
@@ -527,8 +493,8 @@ class PageView(
     fun upCustomTip(textPage: TextPage?) {
         val page = textPage ?: return
         for (target in CUSTOM_TIP_TARGETS) {
-            if (target.tipValue != ReadBookConfig.tipCustom) continue
-            val template = target.customTemplate
+            if (target.currentTipValue() != ReadTipType.tipCustom) continue
+            val template = target.currentCustomTemplate()
             val view = customTipViewFor(target)
             if (template.isEmpty()) {
                 view.setTextIfNotEqual("")
@@ -563,7 +529,6 @@ class PageView(
             pageSize = textPage.pageSize,
             pageIndex = textPage.index,
             isChapterCompleted = textPage.textChapter.isCompleted,
-            chapterCompleteText = context.getString(R.string.chapter_complete),
         )
         val readProgressDisplay = textPage.readProgress
         val wholeBookPage = ReadBook.getWholeBookPageState(textPage.chapterIndex, textPage.index)
@@ -822,6 +787,8 @@ class PageView(
 
     val selectStartPos get() = binding.contentTextView.selectStart
 
+    val selectEndPos get() = binding.contentTextView.selectEndPosition
+
     companion object {
         /**
          * 缓存枚举数组,避免每次调用 `values()` 触发 Kotlin 内部数组克隆。
@@ -831,3 +798,16 @@ class PageView(
         private val CUSTOM_TIP_PLACEHOLDERS: Array<CustomTipPlaceholder> = CustomTipPlaceholder.values()
     }
 }
+
+/**
+ * 渲染层按位置取当前 tip 类型 / 自定义模板,读的是 [TipStyleProvider] 快照。
+ *
+ * 这两个函数在 `upCustomTip` / `upTime` / `upBattery` 热路径上反复触发,过去每次都直读
+ * 可变全局 `ReadBookConfig`。设置面读的是 `ReadSheetConfigUiState`
+ * (`CustomTipTarget.tipValueOf` / `customTemplateOf`),两侧互不影响。
+ */
+private fun CustomTipTarget.currentTipValue(): Int =
+    TipStyleProvider.style.tipValueOf(this)
+
+private fun CustomTipTarget.currentCustomTemplate(): String =
+    TipStyleProvider.style.customTemplateOf(this)

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

@@ -16,12 +16,7 @@ import android.widget.FrameLayout
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
-import io.legado.app.data.entities.BookProgress
-import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.model.ReadAloud
-import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
-import io.legado.app.service.BaseReadAloudService
 
 import io.legado.app.ui.book.read.page.api.DataSource
 import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate
@@ -39,12 +34,12 @@ import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.activity
 import io.legado.app.utils.invisible
-import io.legado.app.utils.longToastOnUi
 
 import io.legado.app.utils.throttle
 import java.text.BreakIterator
@@ -57,15 +52,15 @@ import kotlin.math.abs
 class ReadView(
     context: Context,
     attrs: AttributeSet? = null,
-    callBack: CallBack? = null,
+    private val callBack: CallBack,
     contentCallBack: ContentTextView.CallBack? = null,
+    private val eventListener: ReaderEventListener,
+    private val pageSource: ReaderPageSource,
 ) :
     FrameLayout(context, attrs),
     DataSource, LayoutProgressListener {
 
-    private var injectedCallBack: CallBack? = callBack
-    val callBack: CallBack get() = injectedCallBack ?: activity as CallBack
-    var pageFactory: TextPageFactory = TextPageFactory(this)
+    var pageFactory: TextPageFactory = TextPageFactory(this, pageSource)
     var pageDelegate: PageDelegate? = null
         private set(value) {
             field?.onDestroy()
@@ -324,7 +319,7 @@ class ReadView(
             AccessibilityNodeInfo.ACTION_CLICK -> {
                 performClick()
                 pageDelegate?.dismissSnackBar()
-                callBack.showActionMenu()
+                eventListener.onEvent(ReaderEvent.ShowActionMenu)
                 true
             }
 
@@ -503,31 +498,22 @@ class ReadView(
         when (action) {
             0 -> {
                 pageDelegate?.dismissSnackBar()
-                callBack.showActionMenu()
+                eventListener.onEvent(ReaderEvent.ShowActionMenu)
             }
 
             1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
             2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
-            3 -> ReadBook.moveToNextChapter(true)
-            4 -> ReadBook.moveToPrevChapter(upContent = true, toLast = false)
-            5 -> ReadAloud.prevParagraph(context)
-            6 -> ReadAloud.nextParagraph(context)
-            7 -> callBack.addBookmark()
-            8 -> callBack.openContentEdit()
-            9 -> callBack.changeReplaceRuleState()
-            10 -> callBack.openChapterList()
-            11 -> callBack.openSearchActivity(null)
-            12 -> ReadBook.syncProgress(
-                { progress -> callBack.sureNewProgress(progress) },
-                { context.longToastOnUi(context.getString(R.string.upload_book_success)) },
-                { context.longToastOnUi(context.getString(R.string.sync_book_progress_success)) })
-            13 -> {
-                if (BaseReadAloudService.isPlay()) {
-                    ReadAloud.pause(context)
-                } else {
-                    ReadAloud.resume(context)
-                }
-            }
+            3 -> eventListener.onEvent(ReaderEvent.NextChapter)
+            4 -> eventListener.onEvent(ReaderEvent.PrevChapter)
+            5 -> eventListener.onEvent(ReaderEvent.ReadAloudPrevParagraph)
+            6 -> eventListener.onEvent(ReaderEvent.ReadAloudNextParagraph)
+            7 -> eventListener.onEvent(ReaderEvent.AddBookmark)
+            8 -> eventListener.onEvent(ReaderEvent.OpenContentEdit)
+            9 -> eventListener.onEvent(ReaderEvent.ChangeReplaceRuleState)
+            10 -> eventListener.onEvent(ReaderEvent.OpenChapterList)
+            11 -> eventListener.onEvent(ReaderEvent.OpenSearch)
+            12 -> eventListener.onEvent(ReaderEvent.SyncProgress)
+            13 -> eventListener.onEvent(ReaderEvent.ToggleReadAloudPause)
         }
     }
 
@@ -586,9 +572,9 @@ class ReadView(
      * 更新翻页动画
      */
     fun upPageAnim(upRecorder: Boolean = false) {
-        isScroll = ReadBook.pageAnim() == 3
+        isScroll = pageSource.pageAnim == 3
         ChapterProvider.upLayout()
-        when (ReadBook.pageAnim()) {
+        when (pageSource.pageAnim) {
             PageAnim.coverPageAnim -> if (pageDelegate !is CoverPageDelegate) {
                 pageDelegate = CoverPageDelegate(this)
             }
@@ -686,6 +672,7 @@ class ReadView(
      */
     fun upStyle() {
         ChapterProvider.upStyle()
+        TipStyleProvider.upTipStyle()
         curPage.upStyle()
         prevPage.upStyle()
         nextPage.upStyle()
@@ -697,6 +684,7 @@ class ReadView(
      */
     fun applyThemeColors() {
         ChapterProvider.upThemeColors()
+        TipStyleProvider.upTipStyle()
         invalidateTextPage()
         ReadSessionState.updateBackground(width, height)
         curPage.apply {
@@ -753,20 +741,10 @@ class ReadView(
     }
 
     /**
-     * 从选择位置开始朗读
+     * 当前页内 行/列 对应的章节内位置(渲染面查询,翻页推进由外层负责)
      */
-    suspend fun aloudStartSelect(selectStartPos: TextPos) {
-        var pagePos = selectStartPos.relativePagePos
-        val line = selectStartPos.lineIndex
-        val column = selectStartPos.columnIndex
-        while (pagePos > 0) {
-            if (!ReadBook.moveToNextPage()) {
-                ReadBook.moveToNextChapterAwait(false)
-            }
-            pagePos--
-        }
-        val startPos = curPage.textPage.getPosByLineColumn(line, column)
-        ReadBook.readAloud(startPos = startPos)
+    fun posByLineColumn(line: Int, column: Int): Int {
+        return curPage.textPage.getPosByLineColumn(line, column)
     }
 
     /**
@@ -804,6 +782,14 @@ class ReadView(
         }
     }
 
+    /**
+     * 供 [io.legado.app.ui.book.read.page.delegate.PageDelegate] 在翻到尽头时出站——
+     * 翻页委托不持有 eventListener,经这里转发(Track D·D1)。
+     */
+    internal fun requestAutoPageStop() {
+        eventListener.onEvent(ReaderEvent.AutoPageStop)
+    }
+
     fun onScrollAnimStart() {
         autoPager.pause()
     }
@@ -832,41 +818,41 @@ class ReadView(
         upProgressThrottle.invoke()
     }
 
+    override val pageIndex: Int get() = pageSource.durPageIndex
+
     override val currentChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(0) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(0) else null
         }
 
     override val nextChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(1) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(1) else null
         }
 
     override val prevChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(-1) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(-1) else null
         }
 
     override fun hasNextChapter(): Boolean {
-        return ReadBook.durChapterIndex < ReadBook.simulatedChapterSize - 1
+        return pageSource.durChapterIndex < pageSource.simulatedChapterSize - 1
     }
 
     override fun hasPrevChapter(): Boolean {
-        return ReadBook.durChapterIndex > 0
+        return pageSource.durChapterIndex > 0
     }
 
+    /**
+     * 与宿主的**瞬时 UI 副作用**协作面(Track D·D1)。
+     *
+     * 业务意图一律走 [ReaderEvent];这里只留不属于业务状态、由 View 直接驱动宿主的那几项,
+     * 外加首帧放行门闩 `isInitFinish`(入站状态查询,随 D2 数据面一起处理)。
+     */
     interface CallBack {
         val isInitFinish: Boolean
-        fun showActionMenu()
         fun screenOffTimerStart()
         fun showTextActionMenu()
-        fun autoPageStop()
-        fun openChapterList()
-        fun openContentEdit()
-        fun addBookmark()
-        fun changeReplaceRuleState()
-        fun openSearchActivity(searchWord: String?)
         fun upSystemUiVisibility()
-        fun sureNewProgress(progress: BookProgress)
     }
 }

+ 43 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt

@@ -0,0 +1,43 @@
+package io.legado.app.ui.book.read.page
+
+/**
+ * `ReadView` 的出站业务意图(Track D·D1,见 docs/dev/mad-modernization-plan.md §Track D)。
+ *
+ * `ReadView` 只做绘制/手势/动画,不认识 `ReadBook`/`ReadAloud`:手势判定出的业务命令以事件发出,
+ * 由外层(`ReadBookController`)翻译成 Intent 或会话调用。
+ *
+ * 不属于业务状态的**瞬时 UI 副作用**(系统栏可见性、息屏计时、文本选择菜单等)不走这里,
+ * 仍由 `ReadView.CallBack` 与宿主直接协作。
+ */
+sealed interface ReaderEvent {
+
+    data object ShowActionMenu : ReaderEvent
+
+    data object AutoPageStop : ReaderEvent
+
+    data object OpenChapterList : ReaderEvent
+
+    data object OpenContentEdit : ReaderEvent
+
+    data object OpenSearch : ReaderEvent
+
+    data object AddBookmark : ReaderEvent
+
+    data object ChangeReplaceRuleState : ReaderEvent
+
+    data object NextChapter : ReaderEvent
+
+    data object PrevChapter : ReaderEvent
+
+    data object ReadAloudPrevParagraph : ReaderEvent
+
+    data object ReadAloudNextParagraph : ReaderEvent
+
+    data object ToggleReadAloudPause : ReaderEvent
+
+    data object SyncProgress : ReaderEvent
+}
+
+fun interface ReaderEventListener {
+    fun onEvent(event: ReaderEvent)
+}

+ 47 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReaderPageSource.kt

@@ -0,0 +1,47 @@
+package io.legado.app.ui.book.read.page
+
+import io.legado.app.ui.book.read.page.entities.TextChapter
+
+/**
+ * 渲染层的页数据入口 + 页位置命令口(Track D·D2,见 docs/dev/mad-modernization-plan.md §Track D)。
+ *
+ * 与出站的 [ReaderEvent] 相对:业务意图往外发、页数据往里读。`ReadView` 与
+ * `TextPageFactory` 都不认识 `ReadBook`,由宿主(`ReadBookController`)实现本接口把数据喂进来、
+ * 把页位置命令接下去。
+ *
+ * 这里刻意是**逐次读取**而不是不可变快照:取页由 `pageFactory` 在绘制期驱动,
+ * 换成发布期快照会改变时序。快照化是 D2 之后的独立议题,不与本步捆绑。
+ *
+ * **为什么页位置命令不走 [ReaderEvent]**:`ReaderEvent` 是即发即忘的出站意图,而
+ * [setPageIndex]/[moveToNextChapter]/[moveToPrevChapter] 必须**同步**生效——
+ * `TextPageFactory.moveToNext/moveToPrev` 下达命令后立即用新位置取页并返回布尔给翻页委托
+ * 决定动画。所以它们是同步命令口,不是事件。
+ */
+interface ReaderPageSource {
+
+    val durChapterIndex: Int
+
+    val durPageIndex: Int
+
+    val simulatedChapterSize: Int
+
+    val pageAnim: Int
+
+    /** 加载中/出错时要顶替正文显示的消息;为 null 表示正常显示正文 */
+    val msg: String?
+
+    /** chapterOnDur: 0 当前章, 1 下一章, -1 上一章 */
+    fun textChapter(chapterOnDur: Int): TextChapter?
+
+    /** 章内翻页:把阅读位置移到本章第 [index] 页 */
+    fun setPageIndex(index: Int)
+
+    /**
+     * 跨章前进。实现方必须用 `upContentInPlace = false`——由 `TextPageFactory` 在命令返回后
+     * 自行决定是否 `upContent`,避免同一次翻页刷两遍内容。
+     */
+    fun moveToNextChapter(upContent: Boolean)
+
+    /** 跨章后退,`upContentInPlace = false` 的约定同 [moveToNextChapter] */
+    fun moveToPrevChapter(upContent: Boolean)
+}

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt

@@ -1,11 +1,10 @@
 package io.legado.app.ui.book.read.page.api
 
-import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.entities.TextChapter
 
 interface DataSource {
 
-    val pageIndex: Int get() = ReadBook.durPageIndex
+    val pageIndex: Int
 
     val currentChapter: TextChapter?
 

Некоторые файлы не были показаны из-за большого количества измененных файлов