Răsfoiți Sursa

Merge main into todoXu/main

HapeLee 3 luni în urmă
părinte
comite
b49fc28e05
100 a modificat fișierele cu 13809 adăugiri și 2088 ștergeri
  1. 104 0
      .github/workflows/retrace-log.yml
  2. 76 0
      .github/workflows/verify.yml
  3. 15 0
      app/build.gradle.kts
  4. 2928 0
      app/lint-baseline.xml
  5. 4668 0
      app/schemas/io.legado.app.data.AppDatabase/99.json
  6. 77 42
      app/src/main/assets/defaultData/txtTocRule.json
  7. 3 3
      app/src/main/java/io/legado/app/App.kt
  8. 0 1
      app/src/main/java/io/legado/app/constant/EventBus.kt
  9. 36 0
      app/src/main/java/io/legado/app/constant/ReadTipType.kt
  10. 1 1
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  11. 34 1
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  12. 16 0
      app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt
  13. 2 2
      app/src/main/java/io/legado/app/data/entities/Book.kt
  14. 7 0
      app/src/main/java/io/legado/app/data/entities/HighlightRule.kt
  15. 4 1
      app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt
  16. 45 0
      app/src/main/java/io/legado/app/data/repository/BookCacheManageRepository.kt
  17. 8 0
      app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt
  18. 36 0
      app/src/main/java/io/legado/app/data/repository/BookImportRepository.kt
  19. 63 1
      app/src/main/java/io/legado/app/data/repository/BookRepository.kt
  20. 157 0
      app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt
  21. 39 0
      app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt
  22. 4 4
      app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt
  23. 7 1
      app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt
  24. 4 0
      app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt
  25. 31 0
      app/src/main/java/io/legado/app/data/repository/HttpTtsRepository.kt
  26. 163 100
      app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt
  27. 73 9
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  28. 139 0
      app/src/main/java/io/legado/app/data/repository/ReadStyleConfigStore.kt
  29. 65 81
      app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt
  30. 54 40
      app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt
  31. 33 0
      app/src/main/java/io/legado/app/data/repository/RssArticleRepository.kt
  32. 6 0
      app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt
  33. 23 0
      app/src/main/java/io/legado/app/data/repository/RssReadRecordRepository.kt
  34. 86 5
      app/src/main/java/io/legado/app/data/repository/RssRepository.kt
  35. 34 0
      app/src/main/java/io/legado/app/data/repository/RssSourceEditRepository.kt
  36. 53 0
      app/src/main/java/io/legado/app/data/repository/SearchRepository.kt
  37. 4 0
      app/src/main/java/io/legado/app/data/repository/TagGroupRuleRepository.kt
  38. 9 4
      app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt
  39. 91 2
      app/src/main/java/io/legado/app/di/appModule.kt
  40. 5 3
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  41. 19 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleGateway.kt
  42. 3 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt
  43. 9 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt
  44. 1 1
      app/src/main/java/io/legado/app/help/config/LocalConfig.kt
  45. 251 620
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  46. 6 17
      app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt
  47. 5 6
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  48. 1 1
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  49. 7 8
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  50. 98 33
      app/src/main/java/io/legado/app/model/Debug.kt
  51. 4 3
      app/src/main/java/io/legado/app/model/FullBookPaginator.kt
  52. 187 35
      app/src/main/java/io/legado/app/model/ReadBook.kt
  53. 138 0
      app/src/main/java/io/legado/app/model/ReaderSession.kt
  54. 42 12
      app/src/main/java/io/legado/app/model/localBook/TextFile.kt
  55. 2 2
      app/src/main/java/io/legado/app/model/webBook/BookList.kt
  56. 3 0
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  57. 2 2
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt
  58. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt
  59. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportDictRuleDialog.kt
  60. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportDictRuleViewModel.kt
  61. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportHttpTtsDialog.kt
  62. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportHttpTtsViewModel.kt
  63. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt
  64. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt
  65. 2 2
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt
  66. 7 4
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt
  67. 3 3
      app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleDialog.kt
  68. 8 5
      app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleViewModel.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  70. 16 13
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt
  71. 5 5
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  72. 6 6
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt
  73. 40 37
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  74. 2 2
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt
  75. 10 5
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
  76. 15 16
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt
  77. 3 3
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt
  78. 28 44
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
  79. 10 18
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt
  80. 13 19
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  81. 9 8
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  82. 3 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  83. 46 39
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  84. 2 2
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt
  85. 9 6
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt
  86. 93 45
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  87. 21 18
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  88. 91 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAiContract.kt
  89. 1352 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt
  90. 438 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  91. 57 140
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  92. 192 33
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  93. 419 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  94. 61 163
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  95. 60 38
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  96. 13 9
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  97. 505 332
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  98. 68 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  99. 159 0
      app/src/main/java/io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt
  100. 21 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateBus.kt

+ 104 - 0
.github/workflows/retrace-log.yml

@@ -0,0 +1,104 @@
+name: Retrace Mapped Log
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: "APP版本,例如 3.26.15 或 3.26.16-beta.9"
+        required: true
+        type: string
+
+      log:
+        description: "Base64 编码后的日志内容"
+        required: true
+        type: string
+
+permissions:
+  contents: read
+
+
+jobs:
+  retrace:
+    name: Retrace Log
+    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: Setup Android SDK Tools
+        uses: android-actions/setup-android@v4
+        with:
+          packages: ''
+          log-accepted-android-sdk-licenses: false
+          
+      - name: Check Retrace Accessibility
+        run: |
+          echo "=== 检查 Retrace 可用性 ==="
+          if command -v retrace &> /dev/null; then
+              retrace --help
+              echo "retrace 可用!"
+          else
+              echo "错误:未检测到 retrace"
+              exit 1
+          fi
+      
+      - name: Save Log
+        env:
+            LOG_BASE64: ${{ inputs.log }}
+        run: |
+          mkdir -p logs
+          
+          if ! echo "$LOG_BASE64" | base64 --decode > logs/obfuscated.txt; then
+            echo "日志 Base64 解码失败"
+            exit 1
+          fi
+          
+      - name: Download Mapping File
+        env:
+          VERSION: ${{ inputs.version }}
+        run: |
+          mkdir -p logs
+          echo "获取版本:"
+          echo "$VERSION"
+          
+          gh release download "$VERSION" \
+            --repo "HapeLee/legado-with-MD3" \
+            --pattern "*mapping*.txt" \
+            --dir logs
+            
+          if [ ! -f logs/mapping.txt ]; then
+            FILE=$(find logs -name "*.txt" | head -n 1)
+            if [ -n "$FILE" ]; then
+              mv "$FILE" logs/mapping.txt
+            fi
+          fi
+          
+          if [ ! -f logs/mapping.txt ]; then
+            echo "未找到 mapping 文件"
+            exit 1
+          fi
+          
+          echo "Mapping:"
+          ls -lh logs/mapping.txt
+
+      - name: Run Retrace
+
+        run: |
+          retrace logs/mapping.txt logs/obfuscated.txt 2>&1 | tee logs/recovered.txt
+
+      - name: Upload Result
+
+        uses: actions/upload-artifact@v7
+
+        with:
+          archive: false
+          path:
+            logs/recovered.txt

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

Fișier diff suprimat deoarece este prea mare
+ 2928 - 0
app/lint-baseline.xml


+ 4668 - 0
app/schemas/io.legado.app.data.AppDatabase/99.json

@@ -0,0 +1,4668 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 99,
+    "identityHash": "0458658832e21f9dde1ecae6a827691f",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `tocLevel` INTEGER NOT NULL DEFAULT 0, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tocLevel",
+            "columnName": "tocLevel",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chapterRule` TEXT NOT NULL, `volumeRule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterRule",
+            "columnName": "chapterRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "volumeRule",
+            "columnName": "volumeRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "highlightRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, `fontWeight` INTEGER NOT NULL, `isItalic` INTEGER NOT NULL, `npLeft` REAL NOT NULL, `npRight` REAL NOT NULL, `npTop` REAL NOT NULL, `npBottom` REAL NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sampleText",
+            "columnName": "sampleText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "targetScope",
+            "columnName": "targetScope",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "textColor",
+            "columnName": "textColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "bgColor",
+            "columnName": "bgColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineMode",
+            "columnName": "underlineMode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineColor",
+            "columnName": "underlineColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineWidth",
+            "columnName": "underlineWidth",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineOffset",
+            "columnName": "underlineOffset",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineSvgPath",
+            "columnName": "underlineSvgPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImage",
+            "columnName": "bgImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImageFit",
+            "columnName": "bgImageFit",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bgImageScale",
+            "columnName": "bgImageScale",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "configName",
+            "columnName": "configName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontPath",
+            "columnName": "fontPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontWeight",
+            "columnName": "fontWeight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isItalic",
+            "columnName": "isItalic",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npLeft",
+            "columnName": "npLeft",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npRight",
+            "columnName": "npRight",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npTop",
+            "columnName": "npTop",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npBottom",
+            "columnName": "npBottom",
+            "affinity": "REAL",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_provider_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "protocol",
+            "columnName": "protocol",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelsUrl",
+            "columnName": "modelsUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authType",
+            "columnName": "authType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secretRef",
+            "columnName": "secretRef",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "headersJson",
+            "columnName": "headersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chatPath",
+            "columnName": "chatPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "responsesPath",
+            "columnName": "responsesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "messagesPath",
+            "columnName": "messagesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "modelsPath",
+            "columnName": "modelsPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customHeadersJson",
+            "columnName": "customHeadersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_model_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "providerId",
+            "columnName": "providerId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelId",
+            "columnName": "modelId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contextWindow",
+            "columnName": "contextWindow",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maxOutputTokens",
+            "columnName": "maxOutputTokens",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultParamsJson",
+            "columnName": "defaultParamsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_model_profiles_providerId",
+            "unique": false,
+            "columnNames": [
+              "providerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_task_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptTemplate",
+            "columnName": "promptTemplate",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramsJson",
+            "columnName": "paramsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chunkPolicyJson",
+            "columnName": "chunkPolicyJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isDefault",
+            "columnName": "isDefault",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_task_presets_taskType",
+            "unique": false,
+            "columnNames": [
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)"
+          },
+          {
+            "name": "index_ai_task_presets_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_artifacts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptHash",
+            "columnName": "promptHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "output",
+            "columnName": "output",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "errorMessage",
+            "columnName": "errorMessage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)"
+          },
+          {
+            "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "contentHash",
+              "promptHash",
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_chat_conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reasoningLevel",
+            "columnName": "reasoningLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_chat_messages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "partsJson",
+            "columnName": "partsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "branchIndex",
+            "columnName": "branchIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isSelected",
+            "columnName": "isSelected",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parentMessageId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "thinkingDuration",
+            "columnName": "thinkingDuration",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_chat_messages_conversationId_createdAt",
+            "unique": false,
+            "columnNames": [
+              "conversationId",
+              "createdAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_memory",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "conversationId",
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_memory_conversationId",
+            "unique": false,
+            "columnNames": [
+              "conversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "highlight_tag_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "tag_group_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "book_content_processes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `kind` TEXT NOT NULL, `stage` TEXT NOT NULL, `target` TEXT NOT NULL, `anchorJson` TEXT NOT NULL, `actionJson` TEXT NOT NULL, `styleJson` TEXT, `source` TEXT NOT NULL, `aiArtifactId` TEXT, `sourceContentHash` TEXT, `enabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `status` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stage",
+            "columnName": "stage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "target",
+            "columnName": "target",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actionJson",
+            "columnName": "actionJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aiArtifactId",
+            "columnName": "aiArtifactId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceContentHash",
+            "columnName": "sourceContentHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "enabled",
+              "sortOrder"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `enabled`, `sortOrder`)"
+          },
+          {
+            "name": "index_book_content_processes_bookUrl_kind",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "kind"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_kind` ON `${TABLE_NAME}` (`bookUrl`, `kind`)"
+          },
+          {
+            "name": "index_book_content_processes_aiArtifactId",
+            "unique": false,
+            "columnNames": [
+              "aiArtifactId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_aiArtifactId` ON `${TABLE_NAME}` (`aiArtifactId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_prompt_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `instruction` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `builtIn` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instruction",
+            "columnName": "instruction",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "builtIn",
+            "columnName": "builtIn",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_prompt_presets_taskType_enabled_sortNumber",
+            "unique": false,
+            "columnNames": [
+              "taskType",
+              "enabled",
+              "sortNumber"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_prompt_presets_taskType_enabled_sortNumber` ON `${TABLE_NAME}` (`taskType`, `enabled`, `sortNumber`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `aliasesJson` TEXT NOT NULL, `avatarUri` TEXT, `tagsJson` TEXT NOT NULL, `role` TEXT NOT NULL, `voiceGender` TEXT NOT NULL DEFAULT 'unknown', `voiceAgeBand` TEXT NOT NULL DEFAULT 'unknown', `personality` TEXT NOT NULL, `summary` TEXT NOT NULL, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aliasesJson",
+            "columnName": "aliasesJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarUri",
+            "columnName": "avatarUri",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tagsJson",
+            "columnName": "tagsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceGender",
+            "columnName": "voiceGender",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "voiceAgeBand",
+            "columnName": "voiceAgeBand",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "personality",
+            "columnName": "personality",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_profiles_bookUrl_name",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_name` ON `${TABLE_NAME}` (`bookUrl`, `name`)"
+          },
+          {
+            "name": "index_book_character_profiles_bookUrl_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_events",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `characterId` TEXT NOT NULL, `chapterIndex` INTEGER, `chapterTitle` TEXT NOT NULL, `eventTimeText` TEXT NOT NULL, `content` TEXT NOT NULL, `importance` INTEGER NOT NULL, `sourceTextHash` TEXT, `evidenceJson` TEXT NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "chapterTitle",
+            "columnName": "chapterTitle",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eventTimeText",
+            "columnName": "eventTimeText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "importance",
+            "columnName": "importance",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceTextHash",
+            "columnName": "sourceTextHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_events_bookUrl_characterId_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "characterId",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_characterId_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `characterId`, `chapterIndex`)"
+          },
+          {
+            "name": "index_book_character_events_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_relations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `fromCharacterId` TEXT NOT NULL, `toCharacterId` TEXT NOT NULL, `relationType` TEXT NOT NULL, `summary` TEXT NOT NULL, `attitude` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `chapterIndex` INTEGER, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fromCharacterId",
+            "columnName": "fromCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "toCharacterId",
+            "columnName": "toCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "relationType",
+            "columnName": "relationType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attitude",
+            "columnName": "attitude",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId_toCharacterId",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`, `toCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_toCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `toCharacterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_knowledge_entries",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `keywordsJson` TEXT NOT NULL, `content` TEXT NOT NULL, `scopeStartChapter` INTEGER, `scopeEndChapter` INTEGER, `priority` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `evidenceJson` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keywordsJson",
+            "columnName": "keywordsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scopeStartChapter",
+            "columnName": "scopeStartChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "scopeEndChapter",
+            "columnName": "scopeEndChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "priority",
+            "columnName": "priority",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_knowledge_entries_bookUrl_type_priority",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "type",
+              "priority"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_type_priority` ON `${TABLE_NAME}` (`bookUrl`, `type`, `priority`)"
+          },
+          {
+            "name": "index_book_knowledge_entries_bookUrl_title",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "title"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_title` ON `${TABLE_NAME}` (`bookUrl`, `title`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_outline_nodes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `parentId` TEXT, `nodeType` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `startChapterIndex` INTEGER, `endChapterIndex` INTEGER, `order` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentId",
+            "columnName": "parentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "nodeType",
+            "columnName": "nodeType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "startChapterIndex",
+            "columnName": "startChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "endChapterIndex",
+            "columnName": "endChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "nodeType",
+              "startChapterIndex",
+              "endChapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `nodeType`, `startChapterIndex`, `endChapterIndex`)"
+          },
+          {
+            "name": "index_book_outline_nodes_bookUrl_parentId_order",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "parentId",
+              "order"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_parentId_order` ON `${TABLE_NAME}` (`bookUrl`, `parentId`, `order`)"
+          }
+        ]
+      },
+      {
+        "tableName": "read_aloud_voices",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `engineType` TEXT NOT NULL, `engineId` TEXT NOT NULL DEFAULT '', `speakerId` TEXT NOT NULL DEFAULT '', `displayName` TEXT NOT NULL, `traitsJson` TEXT NOT NULL DEFAULT '[]', `emotionCatalogJson` TEXT NOT NULL DEFAULT '[]', `managedBy` TEXT NOT NULL DEFAULT 'user', `enabled` INTEGER NOT NULL DEFAULT 1, `available` INTEGER NOT NULL DEFAULT 1, `revision` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineType",
+            "columnName": "engineType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineId",
+            "columnName": "engineId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "speakerId",
+            "columnName": "speakerId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "traitsJson",
+            "columnName": "traitsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "emotionCatalogJson",
+            "columnName": "emotionCatalogJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "managedBy",
+            "columnName": "managedBy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "available",
+            "columnName": "available",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "revision",
+            "columnName": "revision",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_read_aloud_voices_engineType_engineId_speakerId",
+            "unique": true,
+            "columnNames": [
+              "engineType",
+              "engineId",
+              "speakerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_aloud_voices_engineType_engineId_speakerId` ON `${TABLE_NAME}` (`engineType`, `engineId`, `speakerId`)"
+          },
+          {
+            "name": "index_read_aloud_voices_enabled_displayName",
+            "unique": false,
+            "columnNames": [
+              "enabled",
+              "displayName"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_read_aloud_voices_enabled_displayName` ON `${TABLE_NAME}` (`enabled`, `displayName`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_voice_bindings",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `subjectType` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `voiceId` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `source` TEXT NOT NULL DEFAULT 'user', `confidence` REAL NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`, `subjectType`, `subjectId`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectType",
+            "columnName": "subjectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectId",
+            "columnName": "subjectId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceId",
+            "columnName": "voiceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl",
+            "subjectType",
+            "subjectId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_voice_bindings_bookUrl_subjectType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "subjectType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_bookUrl_subjectType` ON `${TABLE_NAME}` (`bookUrl`, `subjectType`)"
+          },
+          {
+            "name": "index_book_voice_bindings_voiceId",
+            "unique": false,
+            "columnNames": [
+              "voiceId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_voiceId` ON `${TABLE_NAME}` (`voiceId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_analysis",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` TEXT NOT NULL, `resolverVersion` TEXT NOT NULL, `characterRevision` TEXT NOT NULL DEFAULT '', `status` TEXT NOT NULL DEFAULT 'pending', `error` TEXT NOT NULL DEFAULT '', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resolverVersion",
+            "columnName": "resolverVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterRevision",
+            "columnName": "characterRevision",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'pending'"
+          },
+          {
+            "fieldPath": "error",
+            "columnName": "error",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "contentHash",
+              "resolverVersion"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `contentHash`, `resolverVersion`)"
+          },
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_segments",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `analysisId` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `paragraphIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `chapterPosition` INTEGER NOT NULL, `text` TEXT NOT NULL, `roleType` TEXT NOT NULL, `characterId` TEXT, `characterName` TEXT NOT NULL DEFAULT '', `emotion` TEXT NOT NULL DEFAULT '', `confidence` REAL NOT NULL DEFAULT 0, `source` TEXT NOT NULL, `userLocked` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "analysisId",
+            "columnName": "analysisId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paragraphIndex",
+            "columnName": "paragraphIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPosition",
+            "columnName": "chapterPosition",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "text",
+            "columnName": "text",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "roleType",
+            "columnName": "roleType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "characterName",
+            "columnName": "characterName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "emotion",
+            "columnName": "emotion",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userLocked",
+            "columnName": "userLocked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_segments_analysisId_paragraphIndex_start_end",
+            "unique": true,
+            "columnNames": [
+              "analysisId",
+              "paragraphIndex",
+              "start",
+              "end"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_segments_analysisId_paragraphIndex_start_end` ON `${TABLE_NAME}` (`analysisId`, `paragraphIndex`, `start`, `end`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "paragraphIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `paragraphIndex`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_characterId",
+            "unique": false,
+            "columnNames": [
+              "characterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_characterId` ON `${TABLE_NAME}` (`characterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cloud_tts_engines",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `baseUrl` TEXT NOT NULL DEFAULT '', `apiKey` TEXT NOT NULL DEFAULT '', `secretKey` TEXT NOT NULL DEFAULT '', `region` TEXT NOT NULL DEFAULT '', `appId` TEXT NOT NULL DEFAULT '', `model` TEXT NOT NULL DEFAULT '', `optionsJson` TEXT NOT NULL DEFAULT '{}', `enabled` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "provider",
+            "columnName": "provider",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "secretKey",
+            "columnName": "secretKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "region",
+            "columnName": "region",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "appId",
+            "columnName": "appId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "model",
+            "columnName": "model",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "optionsJson",
+            "columnName": "optionsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'{}'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cloud_tts_engines_provider_enabled",
+            "unique": false,
+            "columnNames": [
+              "provider",
+              "enabled"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_cloud_tts_engines_provider_enabled` ON `${TABLE_NAME}` (`provider`, `enabled`)"
+          }
+        ]
+      },
+      {
+        "tableName": "exact_chapter_page_counts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` INTEGER NOT NULL, `layoutSignature` INTEGER NOT NULL, `engineVersion` INTEGER NOT NULL, `pageCount` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookId`, `chapterId`, `layoutSignature`), FOREIGN KEY(`bookId`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookId",
+            "columnName": "bookId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterId",
+            "columnName": "chapterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "layoutSignature",
+            "columnName": "layoutSignature",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineVersion",
+            "columnName": "engineVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pageCount",
+            "columnName": "pageCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookId",
+            "chapterId",
+            "layoutSignature"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_exact_chapter_page_counts_bookId_layoutSignature",
+            "unique": false,
+            "columnNames": [
+              "bookId",
+              "layoutSignature"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_exact_chapter_page_counts_bookId_layoutSignature` ON `${TABLE_NAME}` (`bookId`, `layoutSignature`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookId"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0458658832e21f9dde1ecae6a827691f')"
+    ]
+  }
+}

+ 77 - 42
app/src/main/assets/defaultData/txtTocRule.json

@@ -3,7 +3,8 @@
     "id": -1,
     "enable": true,
     "name": "目录(去空白)",
-    "rule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
+    "chapterRule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 假装第一章前面有空白但我不要",
     "serialNumber": 0
   },
@@ -11,7 +12,8 @@
     "id": -2,
     "enable": true,
     "name": "目录",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 标准的粤语就是这样",
     "serialNumber": 1
   },
@@ -19,7 +21,8 @@
     "id": -3,
     "enable": false,
     "name": "目录(匹配简介)",
-    "rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
+    "chapterRule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "简介 老夫诸葛村夫",
     "serialNumber": 2
   },
@@ -27,7 +30,8 @@
     "id": -4,
     "enable": false,
     "name": "目录(古典、轻小说备用)",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
+    "volumeRule": "第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:卷|集(?![合和]))",
     "example": "第一章 比上面只多了回和话",
     "serialNumber": 3
   },
@@ -35,7 +39,8 @@
     "id": -5,
     "enable": false,
     "name": "数字(纯数字标题)",
-    "rule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "12",
     "serialNumber": 4
   },
@@ -43,7 +48,8 @@
     "id": -6,
     "enable": false,
     "name": "大写数字(纯数字标题)",
-    "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "一百七十",
     "serialNumber": 5
   },
@@ -51,23 +57,26 @@
     "id": -7,
     "enable": false,
     "name": "数字混合(纯数字标题)",
-    "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$",
+    "chapterRule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "12\n一百七十",
     "serialNumber": 6
   },
   {
     "id": -8,
     "enable": true,
-    "name": "数字 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$",
-    "example": "1、这个就是标题",
+    "name": "数字 可选分隔符 标题名称",
+    "chapterRule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-]?.{1,30}$",
+    "volumeRule": "",
+    "example": "1、这个就是标题\n02美好的明天",
     "serialNumber": 7
   },
   {
     "id": -9,
     "enable": true,
     "name": "大写数字 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
+    "volumeRule": "",
     "example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题",
     "serialNumber": 8
   },
@@ -75,7 +84,8 @@
     "id": -10,
     "enable": false,
     "name": "数字混合 分隔符 标题名称",
-    "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
+    "volumeRule": "",
     "example": "1、人参公鸡\n二百二十章 boy next door",
     "serialNumber": 9
   },
@@ -83,7 +93,8 @@
     "id": -11,
     "enable": true,
     "name": "正文 标题/序号",
-    "rule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$",
+    "chapterRule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$",
+    "volumeRule": "",
     "example": "正文 我奶常山赵子龙",
     "serialNumber": 10
   },
@@ -91,7 +102,8 @@
     "id": -12,
     "enable": true,
     "name": "Chapter/Section/Part/Episode 序号 标题",
-    "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
+    "volumeRule": "",
     "example": "Chapter 1 MyGrandmaIsNB",
     "serialNumber": 11
   },
@@ -99,7 +111,8 @@
     "id": -13,
     "enable": false,
     "name": "Chapter(去简介)",
-    "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
+    "chapterRule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
+    "volumeRule": "",
     "example": "Chapter 1 MyGrandmaIsNB",
     "serialNumber": 12
   },
@@ -107,7 +120,8 @@
     "id": -14,
     "enable": true,
     "name": "特殊符号 序号 标题",
-    "rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
+    "chapterRule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
+    "volumeRule": "",
     "example": "【第一章 后面的符号可以没有",
     "serialNumber": 13
   },
@@ -115,7 +129,8 @@
     "id": -15,
     "enable": false,
     "name": "特殊符号 标题(成对)",
-    "rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "chapterRule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "volumeRule": "",
     "example": "『加个直角引号更专业』\n(11)我奶常山赵子聋",
     "serialNumber": 14
   },
@@ -123,7 +138,8 @@
     "id": -16,
     "enable": true,
     "name": "特殊符号 标题(单个)",
-    "rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "chapterRule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
+    "volumeRule": "",
     "example": "☆、晋江作者最喜欢的格式",
     "serialNumber": 15
   },
@@ -131,7 +147,8 @@
     "id": -17,
     "enable": true,
     "name": "章/卷 序号 标题",
-    "rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$",
+    "chapterRule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$",
+    "volumeRule": "",
     "example": "卷五 开源盛世",
     "serialNumber": 16
   },
@@ -139,72 +156,90 @@
     "id": -18,
     "enable": false,
     "name": "顶格标题",
-    "rule": "^\\S.{1,20}$",
+    "chapterRule": "^\\S.{1,20}$",
+    "volumeRule": "",
     "example": "20字以内顶格写的都是标题",
     "serialNumber": 17
   },
   {
     "id": -19,
     "enable": false,
-    "name": "双标题(前向)",
-    "rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
-    "example": "第一章 真正的标题\n第一章 这个不要",
+    "name": "中文顶格标题",
+    "chapterRule": "^[一-龟]{1,20}$",
+    "volumeRule": "",
+    "example": "20字以内顶格写的中文视为标题",
     "serialNumber": 18
   },
   {
     "id": -20,
     "enable": false,
-    "name": "双标题(后向)",
-    "rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
-    "example": "第一章 这个标题不要\n第一章真正的标题",
+    "name": "双标题(前向)",
+    "chapterRule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
+    "volumeRule": "",
+    "example": "第一章 真正的标题\n第一章 这个不要",
     "serialNumber": 19
   },
   {
     "id": -21,
+    "enable": false,
+    "name": "双标题(后向)",
+    "chapterRule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
+    "volumeRule": "",
+    "example": "第一章 这个标题不要\n第一章真正的标题",
+    "serialNumber": 20
+  },
+  {
+    "id": -22,
     "enable": true,
     "name": "书名 括号 序号",
-    "rule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$",
+    "chapterRule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "标题后面数字有括号(12)",
-    "serialNumber": 20
+    "serialNumber": 21
   },
   {
-    "id": -22,
+    "id": -23,
     "enable": true,
     "name": "书名 序号",
-    "rule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$",
+    "chapterRule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$",
+    "volumeRule": "",
     "example": "标题后面数字没有括号124",
-    "serialNumber": 21
+    "serialNumber": 22
   },
   {
-    "id": -23,
+    "id": -24,
     "enable": false,
     "name": "特定字符 标题 特定符号",
-    "rule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
+    "chapterRule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
+    "volumeRule": "",
     "example": "===起这种标题干什么===",
-    "serialNumber": 22
+    "serialNumber": 23
   },
   {
-    "id": -24,
+    "id": -25,
     "enable": true,
     "name": "字数分割 分节阅读",
-    "rule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
+    "chapterRule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
+    "volumeRule": "",
     "example": "分节|分页|分段阅读\n第一页",
-    "serialNumber": 23
+    "serialNumber": 24
   },
   {
-    "id": -25,
+    "id": -26,
     "enable": false,
     "name": "通用规则",
-    "rule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$",
+    "chapterRule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$",
+    "volumeRule": "",
     "example": "激进规则,适配更多非常用格式",
-    "serialNumber": 24
+    "serialNumber": 25
   },
   {
     "id": -100,
     "enable": false,
     "name": "默认分章规则",
-    "rule": "",
+    "chapterRule": "",
+    "volumeRule": "",
     "example": "兜底规则,请勿改动此内容",
     "serialNumber": 99
   }
-]
+]

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

+ 1 - 1
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -110,7 +110,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 98,
+    version = 99,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,

+ 34 - 1
app/src/main/java/io/legado/app/data/DatabaseMigrations.kt

@@ -20,7 +20,7 @@ object DatabaseMigrations {
             migration_31_32, migration_32_33, migration_33_34, migration_34_35,
             migration_35_36, migration_36_37, migration_37_38, migration_38_39,
             migration_39_40, migration_40_41, migration_41_42, migration_42_43,
-            migration_82_83,
+            migration_82_83, migration_98_99,
         )
     }
 
@@ -496,4 +496,37 @@ object DatabaseMigrations {
         columnName = "enabledReview"
     )
     class Migration_64_65 : AutoMigrationSpec
+
+    private val migration_98_99 = object : Migration(98, 99) {
+        override fun migrate(database: SupportSQLiteDatabase) {
+            database.execSQL(
+                """
+                CREATE TABLE IF NOT EXISTS `txtTocRules_new` (
+                    `id` INTEGER NOT NULL,
+                    `name` TEXT NOT NULL,
+                    `chapterRule` TEXT NOT NULL,
+                    `volumeRule` TEXT NOT NULL DEFAULT '',
+                    `example` TEXT,
+                    `serialNumber` INTEGER NOT NULL,
+                    `enable` INTEGER NOT NULL,
+                    PRIMARY KEY(`id`)
+                )
+                """.trimIndent()
+            )
+            database.execSQL(
+                """
+                INSERT INTO txtTocRules_new (id, name, chapterRule, volumeRule, example, serialNumber, enable)
+                SELECT id, name, rule, '', example, serialNumber, enable FROM txtTocRules
+                """.trimIndent()
+            )
+            database.execSQL("DROP TABLE txtTocRules")
+            database.execSQL("ALTER TABLE txtTocRules_new RENAME TO txtTocRules")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN fontWeight INTEGER NOT NULL DEFAULT 400")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN isItalic INTEGER NOT NULL DEFAULT 0")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npLeft REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npRight REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npTop REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npBottom REAL NOT NULL DEFAULT 0.1")
+        }
+    }
 }

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

+ 7 - 0
app/src/main/java/io/legado/app/data/entities/HighlightRule.kt

@@ -26,6 +26,12 @@ data class HighlightRule(
     var bgImageScale: Float = 1f,
     var configName: String? = null,
     var fontPath: String? = null,
+    var fontWeight: Int = 400,
+    var isItalic: Boolean = false,
+    var npLeft: Float = 0.1f,
+    var npRight: Float = 0.1f,
+    var npTop: Float = 0.1f,
+    var npBottom: Float = 0.1f,
 ) {
 
     fun styleSummary(): String {
@@ -54,6 +60,7 @@ data class HighlightRule(
                 when (bgImageFit) {
                     1 -> "背景图(拉伸)"
                     2 -> "背景图(裁剪)"
+                    3 -> "背景图(九宫格)"
                     else -> "背景图(平铺)"
                 }
             )

+ 4 - 1
app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt

@@ -2,6 +2,7 @@ package io.legado.app.data.entities
 
 import androidx.room.Entity
 import androidx.room.PrimaryKey
+import com.google.gson.annotations.SerializedName
 
 
 @Entity(tableName = "txtTocRules")
@@ -9,7 +10,9 @@ data class TxtTocRule(
     @PrimaryKey
     var id: Long = System.currentTimeMillis(),
     var name: String = "",
-    var rule: String = "",
+    @SerializedName(value = "chapterRule", alternate = ["rule"])
+    var chapterRule: String = "",
+    var volumeRule: String = "",
     var example: String? = null,
     var serialNumber: Int = -1,
     var enable: Boolean = true

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

+ 7 - 1
app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt

@@ -170,10 +170,16 @@ class HighlightRuleRepository(
                 .coerceIn(0f, 20f),
             underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(),
             bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() },
-            bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2),
+            bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 3),
             bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f),
             configName = runCatching { rule.configName }.getOrNull()?.takeIf { it.isNotBlank() },
             fontPath = runCatching { rule.fontPath }.getOrNull()?.takeIf { it.isNotBlank() },
+            fontWeight = runCatching { rule.fontWeight }.getOrDefault(400).coerceIn(300, 700),
+            isItalic = runCatching { rule.isItalic }.getOrDefault(false),
+            npLeft = runCatching { rule.npLeft }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npRight = runCatching { rule.npRight }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npTop = runCatching { rule.npTop }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npBottom = runCatching { rule.npBottom }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
         )
     }
 

+ 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 - 100
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,138 +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.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.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.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
-}
+}

+ 91 - 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
@@ -221,6 +242,11 @@ import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
+import io.legado.app.ui.book.source.debug.BookSourceDebugViewModel
+import io.legado.app.ui.book.source.manage.BookSourceViewModel
+import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
+import io.legado.app.ui.rss.source.debug.RssSourceDebugViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
@@ -285,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)
@@ -330,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)
@@ -405,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())
@@ -443,10 +482,21 @@ val appModule = module {
     }
 
     viewModelOf(::DictRuleViewModel)
+    viewModelOf(::ImportBookSourceViewModel)
+    viewModelOf(::ImportDictRuleViewModel)
+    viewModelOf(::ImportHttpTtsViewModel)
+    viewModelOf(::ImportReplaceRuleViewModel)
+    viewModelOf(::ImportRssSourceViewModel)
+    viewModelOf(::ImportTxtTocRuleViewModel)
     viewModelOf(::HighlightTagRuleViewModel)
     viewModelOf(::TagGroupRuleViewModel)
     viewModelOf(::DictViewModel)
     viewModelOf(::RssSourceViewModel)
+    viewModelOf(::BookSourceViewModel)
+    viewModelOf(::BookSourceEditViewModel)
+    viewModelOf(::BookSourceDebugViewModel)
+    viewModelOf(::RssSourceEditViewModel)
+    viewModelOf(::RssSourceDebugViewModel)
     viewModelOf(::RssSortViewModel)
     viewModelOf(::RssArticlesViewModel)
     viewModelOf(::ReadRssViewModel)
@@ -465,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(),
@@ -513,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,
@@ -602,6 +686,11 @@ val appModule = module {
             downloadCacheSettingsGateway = get(),
             backupSettingsGateway = get(),
             themeSettingsGateway = get(),
+            httpTtsRepository = get(),
+            bookSourceRepository = get(),
+            bookmarkRepository = get(),
+            bookRepository = get(),
+            readerSession = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)
@@ -633,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()
 }

+ 3 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt

@@ -32,6 +32,7 @@ enum class ReadStyleIntKey {
     TipFooterMiddle,
     TipFooterRight,
     HeaderFontSize,
+    FooterFontSize,
     PageAnim,
     UnderlineHeight,
     UnderlinePadding,
@@ -71,6 +72,7 @@ enum class ReadStyleBooleanKey {
     UnderlineExtend,
     ShowHeaderLine,
     ShowFooterLine,
+    ApplyHeaderStyle,
     StatusIconDark,
 }
 
@@ -80,6 +82,7 @@ enum class ReadStyleStringKey {
     TitleFont,
     TitleSegFlag,
     HeaderFont,
+    FooterFont,
     CustomTipHeaderLeft,
     CustomTipHeaderMiddle,
     CustomTipHeaderRight,

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

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

@@ -99,7 +99,7 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
         get() = !isLastVersion(6, "httpTtsVersion")
 
     val needUpTxtTocRule: Boolean
-        get() = !isLastVersion(3, "txtTocRuleVersion")
+        get() = !isLastVersion(4, "txtTocRuleVersion")
 
     val needUpRssSources: Boolean
         get() = !isLastVersion(6, "rssSourceVersion")

Fișier diff suprimat deoarece este prea mare
+ 251 - 620
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")

+ 98 - 33
app/src/main/java/io/legado/app/model/Debug.kt

@@ -14,11 +14,16 @@ import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import java.util.concurrent.atomic.AtomicLong
 import java.text.SimpleDateFormat
 import java.util.*
 
 object Debug {
-    var callback: Callback? = null
+    private val nextSessionId = AtomicLong()
+    private var activeSession: Session? = null
     private var debugSource: String? = null
     private val tasks: CompositeCoroutine = CompositeCoroutine()
     val debugMessageMap = HashMap<String, String>()
@@ -42,17 +47,28 @@ object Debug {
             Log.d("sourceDebug", msg)
         }
         //调试信息始终要执行
-        callback?.let {
-            if ((debugSource != sourceUrl || !print)) return
-            var printMsg = msg
-            if (isHtml) {
-                printMsg = HtmlFormatter.format(msg)
-            }
+        val event = Event(
+            kind = when (state) {
+                -1 -> EventKind.Error
+                10 -> EventKind.SearchSource
+                20 -> EventKind.InfoSource
+                30 -> EventKind.TocSource
+                40 -> EventKind.ContentSource
+                1000 -> EventKind.Completed
+                else -> EventKind.Message
+            },
+            message = if (isHtml) HtmlFormatter.format(msg) else msg,
+            timestamp = System.currentTimeMillis(),
+            elapsedMillis = System.currentTimeMillis() - startTime,
+        )
+        if (debugSource == sourceUrl && print) {
+            var printMsg = event.message
             if (showTime) {
                 val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime))
                 printMsg = "$time $printMsg"
             }
-            it.printLog(state, printMsg)
+            val structuredEvent = event.copy(message = printMsg)
+            activeSession?.emit(structuredEvent)
         }
         if (isChecking && sourceUrl != null && (msg).length < 30) {
             var printMsg = msg
@@ -71,18 +87,30 @@ object Debug {
 
     @Synchronized
     fun log(msg: String?) {
-        log(debugSource, msg ?: "", true)
+        log(debugSource, if (msg == null) "" else msg, true)
     }
 
-    fun cancelDebug(destroy: Boolean = false) {
+    @Synchronized
+    private fun replaceSession(sourceUrl: String): Session {
         tasks.clear()
+        activeSession?.close()
+        debugSource = sourceUrl
+        startTime = System.currentTimeMillis()
+        return Session(nextSessionId.incrementAndGet(), sourceUrl).also { activeSession = it }
+    }
 
-        if (destroy) {
-            debugSource = null
-            callback = null
-        }
+    @Synchronized
+    private fun cancel(session: Session) {
+        if (activeSession?.id != session.id) return
+        tasks.clear()
+        activeSession = null
+        debugSource = null
+        session.close()
     }
 
+    val hasActiveSession: Boolean
+        @Synchronized get() = activeSession != null
+
     fun startChecking(source: BookSource) {
         isChecking = true
         debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis()
@@ -94,7 +122,8 @@ object Debug {
     }
 
     fun getRespondTime(sourceUrl: String): Long {
-        return debugTimeMap[sourceUrl] ?: CheckSource.timeout
+        val responseTime = debugTimeMap[sourceUrl]
+        return if (responseTime == null) CheckSource.timeout else responseTime
     }
 
     fun updateFinalMessage(sourceUrl: String, state: String) {
@@ -107,11 +136,13 @@ object Debug {
         }
     }
 
-    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
+    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         log(debugSource, "︾开始解析")
-        val sort = rssSource.sortUrls().first()
+        val sort = runCatching { rssSource.sortUrls().first() }.getOrElse {
+            log(debugSource, it.stackTraceStr, state = -1)
+            return session
+        }
         Rss.getArticles(scope, sort.first, sort.second, rssSource, 1)
             .onSuccess {
                 if (it.first.isEmpty()) {
@@ -136,12 +167,11 @@ object Debug {
             .onError {
                 log(debugSource, it.stackTraceStr, state = -1)
             }
+        return session
     }
 
-    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         when {
             key.contains("@js:") -> {
                 val ruleContent = rssSource.ruleContent
@@ -186,13 +216,14 @@ object Debug {
                 val searchUrl = rssSource.searchUrl
                 if (searchUrl.isNullOrEmpty()) {
                     log(debugSource, "⇒搜索URL为空", state = -1)
-                    return
+                    return session
                 }
                 log(debugSource, "⇒开始搜索关键字:$key")
                 log(debugSource, "︾开始解析搜索页")
                 rssSortDebug(scope, rssSource, "搜索", searchUrl, key)
             }
         }
+        return session
     }
 
     private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) {
@@ -239,10 +270,8 @@ object Debug {
             }
     }
 
-    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) {
-        cancelDebug()
-        debugSource = bookSource.bookSourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String): Session {
+        val session = replaceSession(bookSource.bookSourceUrl)
         when {
             key.isAbsUrl() -> {
                 val book = Book()
@@ -283,6 +312,7 @@ object Debug {
                 searchDebug(scope, bookSource, key)
             }
         }
+        return session
     }
 
     private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) {
@@ -353,10 +383,11 @@ object Debug {
                 log(debugSource, showTime = false)
                 val toc = chapters.filter { !(it.isVolume && it.url.startsWith(it.title)) }
                 if (toc.isEmpty()) {
-                    log(debugSource, "≡没有正文章节")
+                    log(debugSource, "≡没有正文章节", state = -1)
                     return@onSuccess
                 }
-                val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
+                val secondChapter = toc.getOrNull(1)
+                val nextChapterUrl = if (secondChapter == null) toc.first().url else secondChapter.url
                 contentDebug(scope, bookSource, book, toc.first(), nextChapterUrl)
             }
             .onError {
@@ -388,7 +419,41 @@ object Debug {
         tasks.add(content)
     }
 
-    interface Callback {
-        fun printLog(state: Int, msg: String)
+    enum class EventKind {
+        Message, SearchSource, InfoSource, TocSource, ContentSource, Error, Completed;
+
+        val isSourcePayload: Boolean
+            get() = this == SearchSource || this == InfoSource || this == TocSource || this == ContentSource
+        val isTerminal: Boolean get() = this == Error || this == Completed
+    }
+
+    data class Event(
+        val kind: EventKind,
+        val message: String,
+        val timestamp: Long,
+        val elapsedMillis: Long,
+    )
+
+    class Session internal constructor(
+        internal val id: Long,
+        val sourceUrl: String,
+    ) {
+        private val channel = Channel<Event>(Channel.UNLIMITED)
+        val events: Flow<Event> = channel.receiveAsFlow()
+
+        internal fun emit(event: Event) {
+            channel.trySend(event)
+            if (event.kind == EventKind.Error || event.kind == EventKind.Completed) {
+                if (activeSession?.id == id) {
+                    activeSession = null
+                    debugSource = null
+                }
+                close()
+            }
+        }
+
+        internal fun close() { channel.close() }
+
+        fun cancel() { Debug.cancel(this) }
     }
-}
+}

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

+ 42 - 12
app/src/main/java/io/legado/app/model/localBook/TextFile.kt

@@ -94,11 +94,12 @@ class TextFile(private var book: Book) {
                 charset = book.fileCharset()
                 if (book.tocUrl.isBlank() || modified) {
                     val blockContent = String(buffer, 0, length, charset)
-                    book.tocUrl = getTocRule(blockContent)?.pattern() ?: ""
+                    book.tocUrl = getTocRule(blockContent)?.chapterRule ?: ""
                 }
             }
         }
-        val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE))
+        val volumePattern = getVolumePattern(book.tocUrl)
+        val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE), volumePattern)
         book.wordCount = StringUtils.wordCountFormat(wordCount)
         book.upKind()
         toc.forEachIndexed { index, bookChapter ->
@@ -108,6 +109,7 @@ class TextFile(private var book: Book) {
         }
         var tocLevel = 0
         toc.forEach { chapter ->
+            if (chapter.isVolume) tocLevel = 0
             chapter.tocLevel = tocLevel
             if (chapter.isVolume) tocLevel = 1
         }
@@ -156,7 +158,7 @@ class TextFile(private var book: Book) {
     /**
      * 按规则解析目录
      */
-    private fun analyze(pattern: Pattern?): Pair<ArrayList<BookChapter>, Int> {
+    private fun analyze(pattern: Pattern?, volumePattern: Pattern? = null): Pair<ArrayList<BookChapter>, Int> {
         if (pattern == null || pattern.pattern().isNullOrEmpty()) {
             return analyze()
         }
@@ -261,8 +263,12 @@ class TextFile(private var book: Book) {
                         } else { //否则就block分割之后,上一个章节的剩余内容
                             //获取上一章节
                             val lastChapter = toc.last()
-                            lastChapter.isVolume =
-                                chapterContent.substringAfter(lastChapter.title).isBlank()
+                            if (volumePattern == null) {
+                                lastChapter.isVolume =
+                                    chapterContent.substringAfter(lastChapter.title).isBlank()
+                            } else {
+                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                            }
                             //将当前段落添加上一章去
                             lastChapter.end = lastChapter.end!! + chapterLength
                             lastChapterWordCount += chapterContent.length
@@ -281,8 +287,12 @@ class TextFile(private var book: Book) {
                         if (toc.isNotEmpty()) { //获取章节内容
                             //获取上一章节
                             val lastChapter = toc.last()
-                            lastChapter.isVolume =
-                                chapterContent.substringAfter(lastChapter.title).isBlank()
+                            if (volumePattern == null) {
+                                lastChapter.isVolume =
+                                    chapterContent.substringAfter(lastChapter.title).isBlank()
+                            } else {
+                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                            }
                             lastChapter.end =
                                 lastChapter.start!! + chapterLength
                             lastChapter.wordCount =
@@ -319,7 +329,11 @@ class TextFile(private var book: Book) {
                     it.wordCount = StringUtils.wordCountFormat(lastChapterWordCount)
                 }
             }
+            // 检查最后一章是否为分卷(循环中无法检测到最后一章),并处理长章节拆分
             toc.lastOrNull()?.let { chapter ->
+                if (volumePattern != null) {
+                    chapter.isVolume = volumePattern.matcher(chapter.title).find()
+                }
                 //章节字数太多进行拆分
                 if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) {
                     val end = chapter.end!!
@@ -446,13 +460,13 @@ class TextFile(private var book: Book) {
     /**
      * 获取合适的目录规则
      */
-    private fun getTocRule(content: String): Pattern? {
+    private fun getTocRule(content: String): TxtTocRule? {
         val rules = getTocRules().reversed()
         var maxNum = 1
-        var tocPattern: Pattern? = null
+        var bestRule: TxtTocRule? = null
         for (tocRule in rules) {
             val pattern = try {
-                tocRule.rule.toPattern(Pattern.MULTILINE)
+                tocRule.chapterRule.toPattern(Pattern.MULTILINE)
             } catch (e: PatternSyntaxException) {
                 AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e)
                 continue
@@ -468,10 +482,26 @@ class TextFile(private var book: Book) {
             }
             if (num >= maxNum) {
                 maxNum = num
-                tocPattern = pattern
+                bestRule = tocRule
             }
         }
-        return tocPattern
+        return bestRule
+    }
+
+    /**
+     * 根据章节正则查找对应的分卷正则(搜索全部规则,含已禁用的)
+     */
+    private fun getVolumePattern(chapterPattern: String): Pattern? {
+        if (chapterPattern.isBlank()) return null
+        val rule = appDb.txtTocRuleDao.all.find { it.chapterRule == chapterPattern }
+        val volumeRule = rule?.volumeRule
+        if (volumeRule.isNullOrBlank()) return null
+        return try {
+            volumeRule.toPattern(Pattern.MULTILINE)
+        } catch (e: PatternSyntaxException) {
+            AppLog.put("TXT分卷规则正则语法错误:${rule?.name}\n$e", e)
+            null
+        }
     }
 
     /**

+ 2 - 2
app/src/main/java/io/legado/app/model/webBook/BookList.kt

@@ -289,7 +289,7 @@ object BookList {
     }
 
     private fun checkExploreJson(bookSource: BookSource) {
-        if (Debug.callback == null) {
+        if (!Debug.hasActiveSession) {
             return
         }
         val json = bookSource.exploreKindsJson()
@@ -305,4 +305,4 @@ object BookList {
         }
     }
 
-}
+}

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

+ 5 - 5
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.UnfoldLess
 import androidx.compose.material.icons.filled.UnfoldMore
@@ -53,6 +52,7 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.stateDescription
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.ui.theme.LegadoTheme
@@ -64,10 +64,9 @@ import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.bookmark.BookmarkItem
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -75,7 +74,8 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
@@ -200,7 +200,7 @@ fun AllBookmarkScreen(
                         )
                         TopBarActionButton(
                             onClick = { showMenu = true },
-                            imageVector = Icons.Default.MoreVert,
+                            imageVector = AppIcons.MoreVert,
                             contentDescription = stringResource(R.string.more_menu)
                         )
                         RoundDropdownMenu(

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

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -39,7 +39,6 @@ import androidx.compose.material.icons.filled.BookmarkAdd
 import androidx.compose.material.icons.filled.Code
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Group
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.Timeline
@@ -81,8 +80,8 @@ import coil.compose.AsyncImage
 import coil.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
-import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -105,6 +104,7 @@ import io.legado.app.ui.widget.components.card.HighlightTagRow
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
@@ -650,7 +650,7 @@ private fun BookInfoOverflowAction(
     Box {
         TopBarActionButton(
             onClick = { expanded = true },
-            imageVector = Icons.Default.MoreVert,
+            imageVector = AppIcons.MoreVert,
             contentDescription = stringResource(R.string.more_actions),
         )
         BookInfoOverflowMenu(

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

+ 93 - 45
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Bookmarks
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.Upload
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.RadioButton
+import androidx.compose.material3.VerticalDivider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -52,6 +54,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
@@ -78,13 +81,12 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.adaptiveContentPadding
-import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButtonMenu
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FabMenuItem
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -99,10 +101,10 @@ import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.utils.ACache
@@ -712,11 +714,13 @@ private fun BookshelfManageScreen(
                         Column(
                             modifier = Modifier
                                 .fillMaxWidth()
-                                .adaptiveHorizontalPadding(vertical = 12.dp),
+                                .padding(vertical = 12.dp, horizontal = 4.dp),
                             verticalArrangement = Arrangement.SpaceBetween
                         ) {
                             Row(
-                                modifier = Modifier.fillMaxWidth(),
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 4.dp),
                                 horizontalArrangement = Arrangement.spacedBy(8.dp)
                             ) {
                                 Column(
@@ -725,14 +729,27 @@ private fun BookshelfManageScreen(
                                 ) {
                                     AppText(
                                         text = book.name,
+                                        modifier = Modifier
+                                            .clip(RoundedCornerShape(4.dp))
+                                            .clickable {
+                                                viewModel.dispatch(
+                                                    BookshelfManageScreenIntent.OpenBookInfoPreview(
+                                                        book,
+                                                        true
+                                                    )
+                                                )
+                                            }
+                                            .padding(horizontal = 4.dp),
                                         style = LegadoTheme.typography.titleSmallEmphasized,
                                         maxLines = 1
                                     )
                                     AppText(
+                                        modifier = Modifier.padding(horizontal = 4.dp),
                                         text = book.getRealAuthor(),
                                         style = LegadoTheme.typography.bodySmall
                                     )
                                     AppText(
+                                        modifier = Modifier.padding(horizontal = 4.dp),
                                         text = "${groupNameResolver(book)} | ${book.originName.ifBlank { book.origin }}",
                                         style = LegadoTheme.typography.labelSmallEmphasized.copy(color = LegadoTheme.colorScheme.primary)
                                     )
@@ -752,27 +769,69 @@ private fun BookshelfManageScreen(
                                     }
                                 )
                             }
-                            Row(
+                            Column(
                                 modifier = Modifier
                                     .fillMaxWidth()
-                                    .padding(top = 4.dp),
-                                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                                verticalAlignment = Alignment.CenterVertically
+                                    .padding(top = 12.dp),
+                                verticalArrangement = Arrangement.spacedBy(4.dp)
                             ) {
-                                if (downloadFailureText != null) {
-                                    AppText(
-                                        text = downloadFailureText,
-                                        modifier = Modifier.weight(1f),
-                                        style = LegadoTheme.typography.labelSmall,
-                                        color = LegadoTheme.colorScheme.error,
-                                        maxLines = 1
-                                    )
-                                } else {
-                                    Spacer(modifier = Modifier.weight(1f))
-                                }
                                 Row(
-                                    horizontalArrangement = Arrangement.spacedBy(6.dp)
+                                    modifier = Modifier.fillMaxWidth(),
+                                    verticalAlignment = Alignment.CenterVertically
                                 ) {
+                                    SmallTonalButton(
+                                        onClick = {
+                                            if (!book.isLocal) {
+                                                viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
+                                            }
+                                        },
+                                        modifier = Modifier.weight(1f),
+                                        icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
+                                        text = if (isDownloading) "停止" else "下载",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
+                                    SmallTonalButton(
+                                        onClick = { exportBook(book) },
+                                        modifier = Modifier.weight(1f),
+                                        icon = Icons.Default.Upload,
+                                        text = "导出",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
+                                    SmallTonalButton(
+                                        onClick = {
+                                            pendingMoveGroupBookUrl = book.bookUrl
+                                            groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
+                                            showGroupSelectSheet = true
+                                        },
+                                        modifier = Modifier.weight(1f),
+                                        icon = Icons.Default.Bookmarks,
+                                        text = "分组",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
                                     RoundDropdownMenu(
                                         expanded = moreMenuBookUrl == book.bookUrl,
                                         onDismissRequest = { moreMenuBookUrl = null }
@@ -805,33 +864,22 @@ private fun BookshelfManageScreen(
                                             }
                                         )
                                     }
-                                    SmallTonalButton(
-                                        onClick = {
-                                            if (!book.isLocal) {
-                                                viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
-                                            }
-                                        },
-                                        icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
-                                        contentDescription = stringResource(R.string.action_download)
-                                    )
-                                    SmallTonalButton(
-                                        onClick = { exportBook(book) },
-                                        icon = Icons.Default.Upload,
-                                        contentDescription = stringResource(R.string.upload_to_remote)
-                                    )
-                                    SmallTonalButton(
-                                        onClick = {
-                                            pendingMoveGroupBookUrl = book.bookUrl
-                                            groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
-                                            showGroupSelectSheet = true
-                                        },
-                                        icon = Icons.Default.Bookmarks,
-                                        contentDescription = stringResource(R.string.group)
-                                    )
                                     SmallTonalButton(
                                         onClick = { moreMenuBookUrl = book.bookUrl },
+                                        modifier = Modifier.weight(1f),
                                         icon = Icons.Default.MoreVert,
-                                        contentDescription = stringResource(R.string.more_menu)
+                                        text = "更多",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                }
+                                if (downloadFailureText != null) {
+                                    AppText(
+                                        text = downloadFailureText,
+                                        style = LegadoTheme.typography.labelSmall,
+                                        color = LegadoTheme.colorScheme.error,
+                                        maxLines = 1
                                     )
                                 }
                             }

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

+ 57 - 140
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
@@ -58,11 +55,9 @@ data class ReadBookMenuState(
 sealed interface ReadBookMenuRoute {
     data object Main : ReadBookMenuRoute
     data object ReadStyle : ReadBookMenuRoute
-    data object TextTitle : ReadBookMenuRoute
     data object ReadAloud : ReadBookMenuRoute
     data object AutoRead : ReadBookMenuRoute
-    data object PaddingConfig : ReadBookMenuRoute
-    data object HeaderFooterConfig : ReadBookMenuRoute
+    data object TypographyConfig : ReadBookMenuRoute
 }
 
 @Stable
@@ -94,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(),
@@ -105,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,
@@ -154,80 +156,40 @@ data class ReadSheetConfigUiState(
     val footerPaddingBottom: Int = 0,
     val footerPaddingLeft: Int = 0,
     val footerPaddingRight: Int = 0,
+    // 页眉页脚字体
+    val headerFont: String = "",
+    val footerFont: String = "",
+    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,
+    val tipFooterColorNight: Int = 0,
+    val textFullJustify: Boolean = true,
+    val textBottomJustify: Boolean = true,
     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,
@@ -289,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,
@@ -327,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
@@ -353,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,
@@ -425,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,
@@ -637,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
 
@@ -745,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
@@ -851,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
@@ -943,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
@@ -1003,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
@@ -1196,6 +1099,15 @@ sealed interface ConfigUpdate {
     data class HeaderFontSize(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
+    data class FooterFont(val path: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
+    data class FooterFontSize(val value: Int) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
+    data class ApplyHeaderStyle(val value: Boolean) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
@@ -1324,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)
     }
 
@@ -1398,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()
+        }
+    }
+
+}

+ 61 - 163
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -81,6 +81,7 @@ import androidx.compose.material.icons.filled.SwapVert
 import androidx.compose.material.icons.filled.Sync
 import androidx.compose.material.icons.filled.Toc
 import androidx.compose.material.icons.filled.Translate
+import androidx.compose.material.icons.filled.Tune
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
@@ -158,12 +159,10 @@ import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.book.read.sheet.AutoReadContent
-import io.legado.app.ui.book.read.sheet.HeaderFooterPage
-import io.legado.app.ui.book.read.sheet.PaddingConfigContent
 import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
 import io.legado.app.ui.book.read.sheet.ReadStyleContent
-import io.legado.app.ui.book.read.sheet.ReadStyleTextTitleContent
+import io.legado.app.ui.book.read.sheet.TypographyPage
 import io.legado.app.ui.book.read.sheet.readMenuButtonInfos
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppSlider
@@ -171,6 +170,7 @@ import io.legado.app.ui.widget.components.AppVerticalSlider
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -210,15 +210,14 @@ fun ReadBookMenuBar(
     } else {
         ReadBookMenuContent.Route(currentRoute)
     }
-    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig || currentRoute == ReadBookMenuRoute.HeaderFooterConfig
+    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.TypographyConfig
     var readStylePage by remember { mutableIntStateOf(0) }
     LaunchedEffect(currentRoute) {
         if (currentRoute != ReadBookMenuRoute.ReadStyle) {
             readStylePage = 0
         }
     }
-    val hideTopBar = dialogLikeRoute ||
-            currentRoute == ReadBookMenuRoute.TextTitle
+    val hideTopBar = dialogLikeRoute
     val menuColors = readMenuColors(preferences.readBarStyle)
 
     Box(Modifier.fillMaxSize()) {
@@ -436,7 +435,7 @@ private fun ReadBookMenuSurface(
         is ReadBookMenuContent.Route -> contentTarget.route
     }
     val expanded = route != ReadBookMenuRoute.Main
-    val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig || route == ReadBookMenuRoute.HeaderFooterConfig
+    val dialogLikeRoute = route == ReadBookMenuRoute.TypographyConfig
     val density = LocalDensity.current
     val windowSize = LocalWindowInfo.current.containerSize
     var surfaceHeightPx by remember { mutableIntStateOf(0) }
@@ -700,11 +699,8 @@ private fun ReadBookMenuSurface(
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
                             ReadStyleContent(
-                                onOpenPaddingConfig = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig))
-                                },
-                                onOpenHeaderFooterConfig = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.HeaderFooterConfig))
+                                onOpenTypographyConfig = {
+                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TypographyConfig))
                                 },
                                 onOpenMoreConfig = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig))
@@ -712,8 +708,11 @@ private fun ReadBookMenuSurface(
                                 onOpenBgTextConfig = { index ->
                                     onIntent(ReadBookIntent.OpenBgTextConfig(index))
                                 },
-                                onOpenTextTitle = {
-                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TextTitle))
+                                onOpenUnderlineConfig = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.UnderlineConfig))
+                                },
+                                onOpenShadowSet = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                 },
                                 onOpenFontSelect = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
@@ -732,48 +731,24 @@ private fun ReadBookMenuSurface(
                         }
                     }
 
-                    ReadBookMenuRoute.PaddingConfig -> {
+                    ReadBookMenuRoute.TypographyConfig -> {
                         ReadBookMenuRoutePage(
-                            title = stringResource(R.string.padding),
+                            title = stringResource(R.string.compose_type),
                             maxHeight = maxHeight,
                             scrollContent = false,
                             animateSize = false,
                             bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
-                            PaddingConfigContent(
+                            TypographyPage(
                                 config = state.sheetConfig,
                                 onIntent = onIntent,
-                                modifier = Modifier.padding(horizontal = 16.dp),
-                            )
-                        }
-                    }
-
-                    ReadBookMenuRoute.HeaderFooterConfig -> {
-                        ReadBookMenuRoutePage(
-                            title = stringResource(R.string.header_footer),
-                            maxHeight = maxHeight,
-                            scrollContent = false,
-                            animateSize = false,
-                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
-                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
-                        ) {
-                            HeaderFooterPage(
-                                onIntent = onIntent,
-                            )
-                        }
-                    }
-
-                    ReadBookMenuRoute.TextTitle -> {
-                        ReadBookMenuRoutePage(
-                            title = stringResource(R.string.read_config_text_effects),
-                            maxHeight = maxHeight,
-                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
-                            animateSize = false,
-                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
-                        ) {
-                            ReadStyleTextTitleContent(
-                                config = state.sheetConfig,
+                                onOpenFontSelect = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
+                                },
+                                onOpenTitleFontSelect = {
+                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TitleFontSelect))
+                                },
                                 onOpenShadowSet = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                 },
@@ -783,13 +758,7 @@ private fun ReadBookMenuSurface(
                                 onOpenHighlightRule = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig))
                                 },
-                                onOpenFontSelect = {
-                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect))
-                                },
-                                onOpenTitleFontSelect = {
-                                    onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TitleFontSelect))
-                                },
-                                onIntent = onIntent,
+                                sameTitleRemoved = state.sameTitleRemoved,
                             )
                         }
                     }
@@ -1747,7 +1716,7 @@ private fun MenuTitleBarMergedGlassButton(
                     },
             ) {
                 Icon(
-                    imageVector = Icons.Default.MoreVert,
+                    imageVector = AppIcons.MoreVert,
                     contentDescription = null,
                     tint = tint,
                     modifier = Modifier.size(20.dp),
@@ -2039,7 +2008,6 @@ private fun OverflowDropdownMenu(
         expanded = expanded,
         onDismissRequest = onDismiss,
     ) { dismiss ->
-        var imageStyleExpanded by remember { mutableStateOf(false) }
 
         // Compact mode: moved from top bar buttons
         if (state.menuConfig.titleBarCompact) {
@@ -2076,23 +2044,7 @@ private fun OverflowDropdownMenu(
             PillDivider()
         }
 
-        // Source actions
-        if (!state.isLocalBook) {
-            RoundDropdownMenuItem(
-                text = stringResource(R.string.menu_refresh_all),
-                leadingIcon = menuIcon(Icons.Default.Replay),
-                onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAll) },
-            )
-        }
-
-        PillDivider()
-
-        // Content operations
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.bookmark_add),
-            leadingIcon = menuIcon(Icons.Default.Bookmark),
-            onClick = { dismiss(); onIntent(ReadBookIntent.AddBookmark) },
-        )
+        // 内容处理
         RoundDropdownMenuItem(
             text = stringResource(R.string.edit_content),
             leadingIcon = menuIcon(Icons.Default.Edit),
@@ -2102,59 +2054,27 @@ private fun OverflowDropdownMenu(
             },
         )
         RoundDropdownMenuItem(
-            text = stringResource(R.string.update_toc),
-            leadingIcon = menuIcon(Icons.Default.Replay),
-            onClick = { dismiss(); onIntent(ReadBookIntent.MenuUpdateToc) },
+            text = stringResource(R.string.bookmark_add),
+            leadingIcon = menuIcon(Icons.Default.Bookmark),
+            onClick = { dismiss(); onIntent(ReadBookIntent.AddBookmark) },
         )
         RoundDropdownMenuItem(
-            text = stringResource(R.string.simulated_reading),
-            leadingIcon = menuIcon(Icons.Default.AutoStories),
+            text = stringResource(R.string.highlight_rule_config),
+            leadingIcon = menuIcon(Icons.Default.Tune),
             onClick = {
                 dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading))
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig))
             },
         )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.reverse_content),
-            leadingIcon = menuIcon(Icons.Default.SwapVert),
-            onClick = { dismiss(); onIntent(ReadBookIntent.MenuReverseContent) },
-        )
-
-        PillDivider()
-
-        // Checkable items
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.replace_rule_title),
-            leadingIcon = menuIcon(Icons.Default.FindReplace),
-            isSelected = state.useReplaceRule,
-            onClick = { onIntent(ReadBookIntent.MenuEnableReplace) },
-        )
         RoundDropdownMenuItem(
             text = stringResource(R.string.replace_rule_title_setting),
             leadingIcon = menuIcon(Icons.Default.Settings),
             onClick = { dismiss(); onIntent(ReadBookIntent.MenuSettingReplace) },
         )
         RoundDropdownMenuItem(
-            text = stringResource(R.string.effective_replaces),
-            leadingIcon = menuIcon(Icons.Default.Rule),
-            onClick = {
-                dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.EffectiveReplaces))
-            },
-        )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.content_processes),
-            leadingIcon = menuIcon(Icons.Default.Edit),
-            onClick = {
-                dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
-            },
-        )
-        RoundDropdownMenuItem(
-            text = stringResource(R.string.same_title_removed),
-            leadingIcon = menuIcon(Icons.Default.CleanHands),
-            isSelected = state.sameTitleRemoved,
-            onClick = { onIntent(ReadBookIntent.MenuSameTitleRemoved) },
+            text = stringResource(R.string.reverse_content),
+            leadingIcon = menuIcon(Icons.Default.SwapVert),
+            onClick = { dismiss(); onIntent(ReadBookIntent.MenuReverseContent) },
         )
         RoundDropdownMenuItem(
             text = stringResource(R.string.re_segment),
@@ -2162,8 +2082,6 @@ private fun OverflowDropdownMenu(
             isSelected = state.reSegment,
             onClick = { onIntent(ReadBookIntent.MenuReSegment) },
         )
-
-        // EPUB
         if (state.isEpub) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.del_ruby_tag),
@@ -2181,47 +2099,7 @@ private fun OverflowDropdownMenu(
 
         PillDivider()
 
-        // Config
-        Box {
-            RoundDropdownMenuItem(
-                text = stringResource(R.string.image_style),
-                leadingIcon = menuIcon(Icons.Default.Image),
-                onClick = { imageStyleExpanded = true },
-            )
-            RoundDropdownMenu(
-                expanded = imageStyleExpanded,
-                onDismissRequest = { imageStyleExpanded = false },
-            ) { subDismiss ->
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.btn_default_s),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleDefault))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_full),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleFull))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_text),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleText))
-                    },
-                )
-                RoundDropdownMenuItem(
-                    text = stringResource(R.string.image_style_single),
-                    onClick = {
-                        subDismiss()
-                        onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleSingle))
-                    },
-                )
-            }
-        }
+        // 阅读设置
         RoundDropdownMenuItem(
             text = stringResource(R.string.book_page_anim),
             leadingIcon = menuIcon(Icons.Default.Animation),
@@ -2231,15 +2109,27 @@ private fun OverflowDropdownMenu(
             },
         )
         RoundDropdownMenuItem(
-            text = stringResource(R.string.config_btn),
-            leadingIcon = menuIcon(Icons.Default.Extension),
+            text = stringResource(R.string.simulated_reading),
+            leadingIcon = menuIcon(Icons.Default.AutoStories),
             onClick = {
                 dismiss()
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ToolButtonConfig))
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading))
             },
         )
 
-        // Progress sync
+        PillDivider()
+
+        // 书源
+        if (!state.isLocalBook) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.menu_refresh_all),
+                leadingIcon = menuIcon(Icons.Default.Replay),
+                onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAll) },
+            )
+            PillDivider()
+        }
+
+        // 进度同步
         if (state.isReadingProgressSyncConfigured) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.get_book_progress),
@@ -2251,10 +2141,18 @@ private fun OverflowDropdownMenu(
                 leadingIcon = menuIcon(Icons.Default.Sync),
                 onClick = { dismiss(); onIntent(ReadBookIntent.MenuCoverProgress) },
             )
+            PillDivider()
         }
 
-        PillDivider()
-
+        // 其他
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.config_btn),
+            leadingIcon = menuIcon(Icons.Default.Extension),
+            onClick = {
+                dismiss()
+                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ToolButtonConfig))
+            },
+        )
         RoundDropdownMenuItem(
             text = stringResource(R.string.log),
             leadingIcon = menuIcon(Icons.Default.BugReport),

+ 60 - 38
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 {
@@ -383,7 +373,12 @@ fun ReadBookRouteScreen(
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
                             ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
                             is ReadBookEffect.MenuSettingReplace -> {
-                                replaceLauncher.launch(Intent(context, ReplaceRuleActivity::class.java))
+                                replaceLauncher.launch(
+                                    ReplaceRuleActivity.startIntent(
+                                        context = context,
+                                        bookUrl = ReadBook.book?.bookUrl
+                                    )
+                                )
                             }
                             is ReadBookEffect.TextActionReplace -> {
                                 val scopes = arrayListOf<String>()
@@ -445,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(
@@ -527,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(
@@ -592,6 +592,10 @@ fun ReadBookRouteScreen(
             }
             ReadBookScreen(
                 state = state,
+                aiState = aiState,
+                highlightRuleState = highlightRuleState,
+                contentEditState = contentEditState,
+                contentProcessState = contentProcessState,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },
@@ -666,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(),
@@ -679,6 +686,8 @@ private fun ReadBookViewLayer(
                     context = context,
                     callBack = readViewCallBack,
                     contentCallBack = contentTextViewCallBack,
+                    eventListener = readerEventListener,
+                    pageSource = readerPageSource,
                 ).apply {
                     layoutParams = FrameLayout.LayoutParams(
                         FrameLayout.LayoutParams.MATCH_PARENT,
@@ -737,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 = {

+ 13 - 9
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,
@@ -184,7 +188,7 @@ fun ReadBookScreen(
         onReplaceRuleChanged = { onIntent(ReadBookIntent.ReplaceRuleChanged) },
         onNavigateToTextEffects = {
             onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TextTitle))
+            onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TypographyConfig))
         },
         onOpenContentProcesses = {
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
@@ -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) },
     )
@@ -532,7 +536,7 @@ fun ReadBookScreen(
         is ReadBookSheet.BookNavigation -> Unit
 
         is ReadBookSheet.InfoConfig -> {
-            // Integrated into ReadStyleSheet's HeaderFooterPage
+            // Integrated into TypographyPage
             LaunchedEffect(state.activeSheet) {
                 onIntent(ReadBookIntent.DismissSheet)
             }

Fișier diff suprimat deoarece este prea mare
+ 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)
+        }
+    }
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff