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

Mad 2 (#1816)

* refactor: 收口 Compose 根配置与主题读取

* refactor: 统一设置快照与主题包原子应用

* refactor: 收口阅读排版与漫画配置

* refactor: 迁移剩余配置与跨层读取

* refactor: 移除 Snapshot 配置桥

* build: 统一生成 universal debug APK

* fix: 避免阅读主题异步解析时闪烁

* 修复 UDF 状态持久化与主题事务回滚

* refactor: 收口 OtherConfig 辅助配置与配置类依赖注入

- CheckSource/DirectLink/本地密码/系统开关抽为 Gateway,OtherConfigViewModel
  去除 Context/Handler/全局单例直写;错误提示归约进 UiState.pendingMessages,
  重启改为 RestartApp Effect
- CheckSource 降为只读投影,写入唯一归 CheckSourceSettingsGateway
- AppConfig/ReadBookConfig 改显式 initialize 注入,移除 GlobalContext 服务定位
- ReadBookConfig 写入统一经 ReadStyleGateway.updateCurrentStyle(ReadStyleMutation)
- 新增架构静态检查:禁止 ReadBookConfig 直写、注入配置类禁用 GlobalContext

* refactor: 迁移 fontSort/customTip 到设置 gateway 管线

- CustomTipTarget: 删除冗余直写 ReadBookConfig 的 setter,持久化统一经 UpdateConfig
- fontSort 收进 OtherSettings gateway,删除 ui.config.FontConfig 门面
- FontSelectGrid 改为 koinInject OtherSettingsGateway 读写

* refactor: 适配 upstream 多角色 TTS 到配置 gateway

rebase 后收口 upstream 新增代码里的旧配置直读/直写:
- ReadBookScreen: 播放器封面改读 CoverSettingsGateway
- ReadAloudPlayerCoordinator: setSpeed 改经 ReadAloudSettingsGateway 持久化(挂起),ViewModel 用 viewModelScope 调用

* refactor: 迁移设置网关阶段 0-2

* refactor: 更新语言配置获取方式并调整 ReadSettingsRepository 注入

* 修复书籍详情、分组与阅读显示问题

* fix: 更新 navigationevent 版本以修复预测式返回崩溃问题

* 修复底部弹层动画与搜索页跳动

* 修复分组封面保存链路

* 修复刷新书架未应用标签分组

* 实现标签分组即时同步

* 实现阅读界面实时跟随系统主题

* 修复阅读主题切换反色与状态分叉

* 书架增加隐藏空分组开关

* 书架布局设置抽屉按功能分区

* fix: 防止恢复备份生成重复本地书

* 修复书籍详情页更多菜单布局

* 修复底栏不透明度滑动条密集刻度

* fix: 原子化分组与标签规则保存

* fix: 修复导入高亮规则导致阅读卡顿
5151561 3 месяцев назад
Родитель
Сommit
9ea19825ae
100 измененных файлов с 3391 добавлено и 2052 удалено
  1. 5 6
      .github/workflows/build-apk-for-user.yml
  2. 7 1
      app/build.gradle.kts
  3. 1 7
      app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt
  4. 1 7
      app/src/appNoR8/generated/baselineProfiles/startup-prof.txt
  5. 1 7
      app/src/appRelease/generated/baselineProfiles/baseline-prof.txt
  6. 1 7
      app/src/appRelease/generated/baselineProfiles/startup-prof.txt
  7. 19 2
      app/src/main/java/io/legado/app/App.kt
  8. 2 0
      app/src/main/java/io/legado/app/base/BaseActivity.kt
  9. 10 4
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  10. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  11. 2 102
      app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt
  12. 2 3
      app/src/main/java/io/legado/app/data/entities/BaseSource.kt
  13. 10 5
      app/src/main/java/io/legado/app/data/entities/Book.kt
  14. 2 3
      app/src/main/java/io/legado/app/data/entities/BookChapter.kt
  15. 2 3
      app/src/main/java/io/legado/app/data/entities/BookGroup.kt
  16. 32 13
      app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt
  17. 52 0
      app/src/main/java/io/legado/app/data/repository/BookExportSettingsRepository.kt
  18. 112 0
      app/src/main/java/io/legado/app/data/repository/BookGroupMutationRepository.kt
  19. 1 2
      app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt
  20. 2 0
      app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt
  21. 82 0
      app/src/main/java/io/legado/app/data/repository/ChangeSourceSettingsRepository.kt
  22. 14 5
      app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt
  23. 189 153
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  24. 145 0
      app/src/main/java/io/legado/app/data/repository/HighlightRuleAssetTransfer.kt
  25. 18 2
      app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt
  26. 29 0
      app/src/main/java/io/legado/app/data/repository/HomepageSettingsRepository.kt
  27. 40 0
      app/src/main/java/io/legado/app/data/repository/ImportBookSettingsRepository.kt
  28. 147 0
      app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt
  29. 170 0
      app/src/main/java/io/legado/app/data/repository/OtherConfigAuxiliaryRepositories.kt
  30. 50 14
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  31. 276 24
      app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt
  32. 29 4
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  33. 36 0
      app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt
  34. 4 2
      app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt
  35. 15 5
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  36. 9 0
      app/src/main/java/io/legado/app/data/repository/SettingsMapping.kt
  37. 18 0
      app/src/main/java/io/legado/app/data/repository/TagGroupRuleApplier.kt
  38. 218 0
      app/src/main/java/io/legado/app/data/repository/ThemePackageSettingsRepository.kt
  39. 65 3
      app/src/main/java/io/legado/app/di/appModule.kt
  40. 2 0
      app/src/main/java/io/legado/app/domain/gateway/AppUiConfigurationGateway.kt
  41. 1 0
      app/src/main/java/io/legado/app/domain/gateway/BackupSettingsGateway.kt
  42. 10 0
      app/src/main/java/io/legado/app/domain/gateway/BookExportSettingsGateway.kt
  43. 20 0
      app/src/main/java/io/legado/app/domain/gateway/BookGroupMutationGateway.kt
  44. 1 0
      app/src/main/java/io/legado/app/domain/gateway/BookshelfSettingsGateway.kt
  45. 12 0
      app/src/main/java/io/legado/app/domain/gateway/ChangeSourceSettingsGateway.kt
  46. 2 15
      app/src/main/java/io/legado/app/domain/gateway/CoverSettingsGateway.kt
  47. 2 11
      app/src/main/java/io/legado/app/domain/gateway/DownloadCacheSettingsGateway.kt
  48. 10 0
      app/src/main/java/io/legado/app/domain/gateway/HomepageSettingsGateway.kt
  49. 10 0
      app/src/main/java/io/legado/app/domain/gateway/ImportBookSettingsGateway.kt
  50. 2 7
      app/src/main/java/io/legado/app/domain/gateway/LabSettingsGateway.kt
  51. 35 0
      app/src/main/java/io/legado/app/domain/gateway/MangaSettingsGateway.kt
  52. 43 0
      app/src/main/java/io/legado/app/domain/gateway/OtherConfigAuxiliaryGateways.kt
  53. 6 0
      app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt
  54. 5 0
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt
  55. 7 0
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  56. 25 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleGateway.kt
  57. 107 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt
  58. 8 0
      app/src/main/java/io/legado/app/domain/gateway/ThemePackageSettingsGateway.kt
  59. 2 7
      app/src/main/java/io/legado/app/domain/gateway/TranslationSettingsGateway.kt
  60. 28 0
      app/src/main/java/io/legado/app/domain/model/BookGroupMutation.kt
  61. 11 1
      app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt
  62. 14 0
      app/src/main/java/io/legado/app/domain/model/settings/BookExportSettings.kt
  63. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt
  64. 30 0
      app/src/main/java/io/legado/app/domain/model/settings/ChangeSourceSettings.kt
  65. 5 0
      app/src/main/java/io/legado/app/domain/model/settings/HomepageSettings.kt
  66. 8 0
      app/src/main/java/io/legado/app/domain/model/settings/ImportBookSettings.kt
  67. 37 0
      app/src/main/java/io/legado/app/domain/model/settings/MangaSettings.kt
  68. 5 0
      app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt
  69. 5 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  70. 4 1
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  71. 20 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt
  72. 98 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeExportData.kt
  73. 4 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  74. 2 0
      app/src/main/java/io/legado/app/domain/model/settings/TranslationSettings.kt
  75. 11 2
      app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt
  76. 17 3
      app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt
  77. 18 11
      app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt
  78. 4 3
      app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt
  79. 2 0
      app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt
  80. 20 11
      app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt
  81. 3 3
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  82. 32 11
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  83. 6 4
      app/src/main/java/io/legado/app/help/book/ContentProcessor.kt
  84. 113 768
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  85. 34 11
      app/src/main/java/io/legado/app/help/config/AppConfigStore.kt
  86. 128 192
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  87. 60 11
      app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt
  88. 61 462
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  89. 164 79
      app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt
  90. 7 2
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  91. 171 0
      app/src/main/java/io/legado/app/help/storage/BookRestorePlanner.kt
  92. 44 19
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  93. 26 4
      app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt
  94. 3 1
      app/src/main/java/io/legado/app/model/AudioPlay.kt
  95. 14 18
      app/src/main/java/io/legado/app/model/CheckSource.kt
  96. 4 5
      app/src/main/java/io/legado/app/model/ImageProvider.kt
  97. 8 4
      app/src/main/java/io/legado/app/model/ReadBook.kt
  98. 3 1
      app/src/main/java/io/legado/app/model/ReadManga.kt
  99. 35 0
      app/src/main/java/io/legado/app/model/ReadSessionState.kt
  100. 2 1
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

+ 5 - 6
.github/workflows/build-apk-for-user.yml

@@ -42,7 +42,7 @@ jobs:
       - name: Build Debug APK
         run: |
           chmod +x gradlew
-          ./gradlew assembleAppDebug --no-daemon --no-configuration-cache
+          ./gradlew assembleAppDebug -PenableAbiSplits=false --no-daemon --no-configuration-cache
         env:
           COMMIT_NUMBER: ${{ github.run_number }}
           APP_VERSION_NAME: debug-${{ github.run_number }}
@@ -52,8 +52,9 @@ jobs:
           APK_PATH="$GITHUB_WORKSPACE/app/build/outputs/apk/app/debug"
           echo "检查 APK 输出目录: $APK_PATH"
           ls -la "$APK_PATH" || true
-          if [ -z "$(find "$APK_PATH" -name "*.apk" -print -quit)" ]; then
-            echo "未找到 APK 文件!"
+          APK_COUNT=$(find "$APK_PATH" -maxdepth 1 -name "*.apk" | wc -l | tr -d ' ')
+          if [ "$APK_COUNT" -ne 1 ]; then
+            echo "预期生成 1 个 universal APK,实际生成 $APK_COUNT 个!"
             exit 1
           fi
 
@@ -61,9 +62,7 @@ jobs:
         run: |
           APK_PATH="$GITHUB_WORKSPACE/app/build/outputs/apk/app/debug"
           cd "$APK_PATH"
-          for f in *.apk; do
-            mv "$f" "legado-debug-${GITHUB_RUN_NUMBER}-${f}"
-          done
+          mv -- *.apk "legado-debug-${GITHUB_RUN_NUMBER}-universal.apk"
           ls -la
 
       - name: Create ZIP Archive

+ 7 - 1
app/build.gradle.kts

@@ -27,6 +27,9 @@ val versionMinor = versionProps["VERSION_MINOR"]?.toString()?.toInt() ?: 0
 val versionPatch = versionProps["VERSION_PATCH"]?.toString()?.toInt() ?: 0
 val appName = "legado"
 val projectVersionName = "$versionMajor.$versionMinor.$versionPatch"
+val enableAbiSplits = providers.gradleProperty("enableAbiSplits")
+    .map(String::toBoolean)
+    .getOrElse(true)
 
 android {
     compileSdk = 37
@@ -118,7 +121,7 @@ android {
 
     splits {
         abi {
-            isEnable = true
+            isEnable = enableAbiSplits
             reset()
             include("armeabi-v7a", "arm64-v8a")
             isUniversalApk = true
@@ -265,6 +268,9 @@ dependencies {
     implementation(libs.androidx.compose.ui.viewbinding)
     implementation(libs.androidx.navigation3.runtime)
     implementation(libs.androidx.navigation3.ui)
+    // 直接声明并抬高 navigationevent 版本,覆盖 navigation3 传递依赖的 1.1.2(预测式返回崩溃)
+    implementation(libs.androidx.navigationevent)
+    implementation(libs.androidx.navigationevent.compose)
     implementation(libs.androidx.compose.adaptive)
     implementation(libs.androidx.compose.adaptive.layout)
     implementation(libs.androidx.compose.adaptive.navigation)

+ 1 - 7
app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt

@@ -22726,12 +22726,6 @@ Lio/legado/app/ui/book/toc/TocViewModel;
 Lio/legado/app/ui/book/toc/rule/TxtTocRuleViewModel;
 Lio/legado/app/ui/config/PrefDelegate;
 Lio/legado/app/ui/config/PrefDelegateKt;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate$default(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/legado/app/ui/config/PrefDelegate;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;)Lio/legado/app/ui/config/PrefDelegate;
-Lio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;-><init>(Landroidx/lifecycle/LifecycleOwner;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->getValue(Ljava/lang/Object;Lkotlin/reflect/KProperty;)Ljava/lang/Object;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->readInitialValue()Ljava/lang/Object;
 Lio/legado/app/ui/config/backupConfig/BackupConfigViewModel;
 Lio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;
 HSPLio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;-><clinit>()V
@@ -31483,4 +31477,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
 Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V

+ 1 - 7
app/src/appNoR8/generated/baselineProfiles/startup-prof.txt

@@ -22726,12 +22726,6 @@ Lio/legado/app/ui/book/toc/TocViewModel;
 Lio/legado/app/ui/book/toc/rule/TxtTocRuleViewModel;
 Lio/legado/app/ui/config/PrefDelegate;
 Lio/legado/app/ui/config/PrefDelegateKt;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate$default(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/legado/app/ui/config/PrefDelegate;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;)Lio/legado/app/ui/config/PrefDelegate;
-Lio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;-><init>(Landroidx/lifecycle/LifecycleOwner;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->getValue(Ljava/lang/Object;Lkotlin/reflect/KProperty;)Ljava/lang/Object;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->readInitialValue()Ljava/lang/Object;
 Lio/legado/app/ui/config/backupConfig/BackupConfigViewModel;
 Lio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;
 HSPLio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;-><clinit>()V
@@ -31483,4 +31477,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
 Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V

+ 1 - 7
app/src/appRelease/generated/baselineProfiles/baseline-prof.txt

@@ -22726,12 +22726,6 @@ Lio/legado/app/ui/book/toc/TocViewModel;
 Lio/legado/app/ui/book/toc/rule/TxtTocRuleViewModel;
 Lio/legado/app/ui/config/PrefDelegate;
 Lio/legado/app/ui/config/PrefDelegateKt;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate$default(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/legado/app/ui/config/PrefDelegate;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;)Lio/legado/app/ui/config/PrefDelegate;
-Lio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;-><init>(Landroidx/lifecycle/LifecycleOwner;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->getValue(Ljava/lang/Object;Lkotlin/reflect/KProperty;)Ljava/lang/Object;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->readInitialValue()Ljava/lang/Object;
 Lio/legado/app/ui/config/backupConfig/BackupConfigViewModel;
 Lio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;
 HSPLio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;-><clinit>()V
@@ -31483,4 +31477,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
 Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V

+ 1 - 7
app/src/appRelease/generated/baselineProfiles/startup-prof.txt

@@ -22726,12 +22726,6 @@ Lio/legado/app/ui/book/toc/TocViewModel;
 Lio/legado/app/ui/book/toc/rule/TxtTocRuleViewModel;
 Lio/legado/app/ui/config/PrefDelegate;
 Lio/legado/app/ui/config/PrefDelegateKt;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate$default(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/legado/app/ui/config/PrefDelegate;
-HSPLio/legado/app/ui/config/PrefDelegateKt;->prefDelegate(Ljava/lang/String;Ljava/lang/Object;Landroidx/lifecycle/LifecycleOwner;Lkotlin/jvm/functions/Function1;)Lio/legado/app/ui/config/PrefDelegate;
-Lio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;-><init>(Landroidx/lifecycle/LifecycleOwner;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->getValue(Ljava/lang/Object;Lkotlin/reflect/KProperty;)Ljava/lang/Object;
-HSPLio/legado/app/ui/config/PrefDelegateKt$prefDelegate$1;->readInitialValue()Ljava/lang/Object;
 Lio/legado/app/ui/config/backupConfig/BackupConfigViewModel;
 Lio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;
 HSPLio/legado/app/ui/config/bookshelfConfig/BookshelfConfig;-><clinit>()V
@@ -31483,4 +31477,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
 Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
 HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V

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

@@ -61,7 +61,6 @@ import io.legado.app.help.storage.Backup
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.model.BookCover
 import io.legado.app.ui.book.read.page.entities.TextLine
-import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.LogUtils
@@ -98,12 +97,30 @@ class App : Application(), ImageLoaderFactory {
         // getApplicationLocales() 恒为空,isEmpty 守卫会形同虚设
         val legacyLanguage = if (!LocalConfig.appLocaleMigrated) {
             LocalConfig.appLocaleMigrated = true
-            OtherConfig.language
+            AppConfigStore.getString(PreferKey.language) ?: "auto"
         } else null
         startKoin {
             androidContext(this@App)
             modules(appDatabaseModule, appModule)
         }
+        AppConfig.initialize(
+            shellGateway = get(),
+            themeGateway = get(),
+            bookshelfGateway = get(),
+            otherGateway = get(),
+            backupGateway = get(),
+            cacheGateway = get(),
+            coverGateway = get(),
+            readGateway = get(),
+            aloudGateway = get(),
+            importBookGateway = get(),
+            exportGateway = get(),
+        )
+        ReadBookConfig.initialize(
+            readStyleRepository = get(),
+            readSettingsGateway = get(),
+        )
+        ReadBookConfig.attachGateway(get())
         if (legacyLanguage != null) {
             get<AppLocaleGateway>().migrateLegacyLanguage(legacyLanguage)
         }

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

@@ -42,6 +42,7 @@ import io.legado.app.utils.applyOpenTint
 import io.legado.app.utils.applyTint
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
+import io.legado.app.utils.isNightMode
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hideSoftInput
 import io.legado.app.utils.LogUtils
@@ -160,6 +161,7 @@ abstract class BaseActivity<VB : ViewBinding>(
 
     override fun onConfigurationChanged(newConfig: Configuration) {
         val previous = lastPlatformConfiguration
+        appUiConfigurationGateway.synchronizeSystemDarkTheme(newConfig.isNightMode)
         super.onConfigurationChanged(newConfig)
         lastPlatformConfiguration = Configuration(newConfig)
         appLocaleGateway.synchronizeFromPlatform()

+ 10 - 4
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.graphics.toArgb
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.WindowCompat
@@ -15,10 +16,10 @@ import androidx.core.os.LocaleListCompat
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.BuildConfig
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.Theme
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.AppUiConfigurationGateway
@@ -27,9 +28,10 @@ import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
-import io.legado.app.utils.LogUtils
+import io.legado.app.utils.isNightMode
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
@@ -66,8 +68,11 @@ abstract class BaseComposeActivity(
 
         setupSystemBar()
         // Compose 入口
+        val initialUiConfiguration = appUiConfigurationGateway.currentConfiguration
         setContent {
-            AppTheme {
+            val uiConfiguration by appUiConfigurationGateway.configuration
+                .collectAsStateWithLifecycle(initialUiConfiguration)
+            AppTheme(configuration = uiConfiguration) {
                 SyncWindowBackground()
                 Content()
             }
@@ -83,6 +88,7 @@ abstract class BaseComposeActivity(
     }
 
     override fun onConfigurationChanged(newConfig: Configuration) {
+        appUiConfigurationGateway.synchronizeSystemDarkTheme(newConfig.isNightMode)
         super.onConfigurationChanged(newConfig)
         // manifest 声明 locale/layoutDirection/screenLayout/uiMode configChanges 后,Compose 主壳
         // 可直接响应语言与深浅色变化,无需销毁 Activity。AppCompat 手动回调本方法时
@@ -113,7 +119,7 @@ abstract class BaseComposeActivity(
             fullScreen
         )
 
-        toggleSystemBar(AppConfig.showStatusBar)
+        toggleSystemBar(appUiConfigurationGateway.currentConfiguration.appShell.showStatusBar)
     }
 
     open fun upBackgroundImage() {

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

@@ -11,6 +11,7 @@ object PreferKey {
     const val showUnread = "showUnread"
     const val showUnreadNew = "showUnreadNew"
     const val bookGroupStyle = "bookGroupStyle"
+    const val hideEmptyGroups = "hideEmptyGroups"
     const val useDefaultCover = "useDefaultCover"
     const val loadCoverOnlyWifi = "loadCoverOnlyWifi"
     const val coverShowName = "coverShowName"

+ 2 - 102
app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt

@@ -1,13 +1,11 @@
 package io.legado.app.data.dao
 
-import androidx.lifecycle.LiveData
 import androidx.room.Dao
 import androidx.room.Delete
 import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
 import androidx.room.Update
-import io.legado.app.constant.BookType
 import io.legado.app.data.entities.BookGroup
 import kotlinx.coroutines.flow.Flow
 
@@ -23,106 +21,8 @@ interface BookGroupDao {
     @Query("SELECT * FROM book_groups ORDER BY `order`")
     fun flowAll(): Flow<List<BookGroup>>
 
-    @get:Query(
-        """
-    with const as (
-        SELECT
-            COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId,
-            COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId
-        FROM book_groups
-    )
-    SELECT book_groups.* FROM book_groups join const 
-    where show > 0 
-    and (
-        (groupId >= 0  and exists (
-            select 1 from books
-            where `group` & book_groups.groupId > 0
-            and (book_groups.isPrivate = 1 or `group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or groupId = ${BookGroup.IdAll}
-        or (groupId = ${BookGroup.IdLocal} and exists (
-            select 1 from books
-            where type & ${BookType.local} > 0
-            and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdAudio} and exists (
-            select 1 from books
-            where type & ${BookType.audio} > 0
-            and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdManga} and exists (
-            select 1 FROM books
-            where type & ${BookType.image} > 0
-            and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdText} and exists (
-            select 1 FROM books
-            where type & ${BookType.text} > 0
-            and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdReading} and exists (
-            SELECT 1 FROM books 
-            WHERE totalChapterNum > 0 
-            AND durChapterIndex > 0 
-            AND durChapterIndex < totalChapterNum - 1
-            AND (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdUnread} and exists (
-            SELECT 1 FROM books 
-            WHERE durChapterIndex = 0 
-            AND durChapterPos = 0
-            AND (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdReadFinished} and exists (
-            SELECT 1 FROM books 
-            WHERE totalChapterNum > 0 
-            AND durChapterIndex >= totalChapterNum - 1
-            AND durChapterPos != 0
-            AND (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdReadFinishedUpdate} and exists (
-            SELECT 1 FROM books 
-            WHERE totalChapterNum > 0 
-            AND durChapterIndex >= totalChapterNum - 1
-            AND canUpdate = 1
-            AND (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        or (groupId = ${BookGroup.IdReadFinishedComplete} and exists (
-            SELECT 1 FROM books 
-            WHERE totalChapterNum > 0 
-            AND durChapterIndex >= totalChapterNum - 1
-            AND canUpdate = 0
-            AND (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        
-        or (groupId = ${BookGroup.IdError} and exists (
-            select 1 from books
-            where type & ${BookType.updateError} > 0
-            and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-        ))
-        
-        or (groupId = ${BookGroup.IdNetNone} 
-            and exists (
-                select 1 from books 
-                where type & ${BookType.audio} = 0
-                and type & ${BookType.local} = 0
-                and (const.sumPublicGroupId & `group`) = 0
-                and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-            )
-        )
-        or (groupId = ${BookGroup.IdLocalNone}
-            and exists (
-                select 1 from books 
-                where type & ${BookType.audio} = 0
-                and type & ${BookType.local} > 0
-                and (const.sumPublicGroupId & `group`) = 0
-                and (`group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
-            )
-        )
-    )
-    ORDER BY `order`"""
-    )
-    val show: LiveData<List<BookGroup>>
+    @Query("SELECT * FROM book_groups WHERE show > 0 ORDER BY `order`")
+    fun flowShow(): Flow<List<BookGroup>>
 
     @Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`")
     fun flowSelect(): Flow<List<BookGroup>>

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

@@ -11,7 +11,6 @@ import io.legado.app.data.entities.rule.RowUi
 import io.legado.app.help.CacheManager
 import io.legado.app.help.ConcurrentRateLimiter.Companion.updateConcurrentRate
 import io.legado.app.help.JsExtensions
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.crypto.SymmetricCryptoAndroid
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.clearExploreKindsCache
@@ -101,7 +100,7 @@ interface BaseSource : JsExtensions {
     /**
      * 解析header规则
      */
-    fun getHeaderMap(hasLoginHeader: Boolean = false) = HashMap<String, String>().apply {
+    fun getHeaderMap(userAgent: String, hasLoginHeader: Boolean = false) = HashMap<String, String>().apply {
         header?.let {
             try {
                 val json = when {
@@ -123,7 +122,7 @@ interface BaseSource : JsExtensions {
             }
         }
         if (!has(AppConst.UA_NAME, true)) {
-            put(AppConst.UA_NAME, AppConfig.userAgent)
+            put(AppConst.UA_NAME, userAgent)
         }
         if (hasLoginHeader) {
             getLoginHeaderMap()?.let {

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

@@ -19,7 +19,6 @@ import io.legado.app.help.book.getFolderNameNoCache
 import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.simulatedTotalChapterNum
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.utils.GSON
@@ -201,7 +200,7 @@ data class Book(
         config.useReplaceRule = useReplaceRule
     }
 
-    fun getUseReplaceRule(): Boolean {
+    fun getUseReplaceRule(defaultReplaceEnabled: Boolean): Boolean {
         val useReplaceRule = config.useReplaceRule
         if (useReplaceRule != null) {
             return useReplaceRule
@@ -210,7 +209,7 @@ data class Book(
         if (isImage || isEpub) {
             return false
         }
-        return AppConfig.replaceEnableDefault
+        return defaultReplaceEnabled
     }
 
     fun setReSegment(reSegment: Boolean) {
@@ -355,12 +354,18 @@ data class Book(
     /**
      * 迁移旧的书籍的一些信息到新的书籍中
      */
-    fun migrateTo(newBook: Book, toc: List<BookChapter>): Book {
+    fun migrateTo(
+        newBook: Book,
+        toc: List<BookChapter>,
+        defaultReplaceEnabled: Boolean,
+        chineseConverterType: Int,
+    ): Book {
         newBook.durChapterIndex = BookHelp
             .getDurChapter(durChapterIndex, durChapterTitle, toc, totalChapterNum)
         newBook.durChapterTitle = toc[newBook.durChapterIndex].getDisplayTitle(
             ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
-            getUseReplaceRule()
+            getUseReplaceRule(defaultReplaceEnabled),
+            chineseConverterType = chineseConverterType,
         )
         newBook.durChapterPos = durChapterPos
         newBook.durChapterTime = durChapterTime

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

@@ -11,7 +11,6 @@ import io.legado.app.constant.AppPattern
 import io.legado.app.data.appDb
 import io.legado.app.exception.RegexTimeoutException
 import io.legado.app.help.RuleBigDataHelp
-import io.legado.app.help.config.AppConfig
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.RuleDataInterface
 import io.legado.app.utils.ChineseUtils
@@ -102,10 +101,11 @@ data class BookChapter(
         replaceRules: List<ReplaceRule>? = null,
         useReplace: Boolean = true,
         chineseConvert: Boolean = true,
+        chineseConverterType: Int,
     ): String {
         var displayTitle = title.replace(AppPattern.rnRegex, "")
         if (chineseConvert) {
-            when (AppConfig.chineseConverterType) {
+            when (chineseConverterType) {
                 1 -> displayTitle = ChineseUtils.t2s(displayTitle)
                 2 -> displayTitle = ChineseUtils.s2t(displayTitle)
             }
@@ -174,4 +174,3 @@ data class BookChapter(
         return String.format("%05d-%s.ttf", index, titleMD5)
     }
 }
-

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

@@ -6,7 +6,6 @@ import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.PrimaryKey
 import io.legado.app.R
-import io.legado.app.help.config.AppConfig
 import kotlinx.parcelize.Parcelize
 
 @Suppress("ConstPropertyName")
@@ -69,9 +68,9 @@ data class BookGroup(
         }
     }
 
-    fun getRealBookSort(): Int {
+    fun getRealBookSort(defaultBookSort: Int): Int {
         if (bookSort < 0) {
-            return AppConfig.bookshelfSort
+            return defaultBookSort
         }
         return bookSort
     }

+ 32 - 13
app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt

@@ -1,29 +1,31 @@
 package io.legado.app.data.repository
 
+import androidx.datastore.preferences.core.Preferences
 import io.legado.app.domain.gateway.AppLocaleGateway
-import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.AppUiConfigurationGateway
-import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.help.config.AppConfigStore
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 
-class AppUiConfigurationRepository(
+class AppUiConfigurationRepository internal constructor(
     private val appLocaleGateway: AppLocaleGateway,
-    private val appShellSettingsGateway: AppShellSettingsGateway,
-    private val themeSettingsGateway: ThemeSettingsGateway,
+    preferencesFlow: StateFlow<Preferences> = AppConfigStore.preferencesFlow,
+    processScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
+    initialSystemDarkTheme: Boolean = false,
 ) : AppUiConfigurationGateway {
 
-    private val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
-    private val initialConfiguration = AppUiConfiguration(
+    private val systemDarkTheme = MutableStateFlow(initialSystemDarkTheme)
+
+    private val initialConfiguration = preferencesFlow.value.toAppUiConfiguration(
         language = appLocaleGateway.currentLanguage,
-        appShell = appShellSettingsGateway.currentSettings,
-        theme = themeSettingsGateway.currentSettings,
+        isSystemDarkTheme = initialSystemDarkTheme,
     )
 
     override val currentConfiguration: AppUiConfiguration
@@ -31,12 +33,29 @@ class AppUiConfigurationRepository(
 
     override val configuration: StateFlow<AppUiConfiguration> = combine(
         appLocaleGateway.language,
-        appShellSettingsGateway.settings,
-        themeSettingsGateway.settings,
-        ::AppUiConfiguration,
-    ).stateIn(
+        preferencesFlow,
+        systemDarkTheme,
+    ) { language, preferences, isSystemDarkTheme ->
+        preferences.toAppUiConfiguration(language, isSystemDarkTheme)
+    }.stateIn(
         scope = processScope,
         started = SharingStarted.Eagerly,
         initialValue = initialConfiguration,
     )
+
+    override fun synchronizeSystemDarkTheme(isDarkTheme: Boolean) {
+        systemDarkTheme.value = isDarkTheme
+    }
 }
+
+internal fun Preferences.toAppUiConfiguration(
+    language: String,
+    isSystemDarkTheme: Boolean,
+): AppUiConfiguration =
+    AppUiConfiguration(
+        language = language,
+        appShell = toAppShellSettings(),
+        theme = toThemeSettings(),
+        cover = toCoverSettings(),
+        isSystemDarkTheme = isSystemDarkTheme,
+    )

+ 52 - 0
app/src/main/java/io/legado/app/data/repository/BookExportSettingsRepository.kt

@@ -0,0 +1,52 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.BookExportSettingsGateway
+import io.legado.app.domain.model.settings.BookExportSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class BookExportSettingsRepository : BookExportSettingsGateway {
+    override val currentSettings: BookExportSettings
+        get() = AppConfigStore.preferences.toBookExportSettings()
+
+    override val settings: Flow<BookExportSettings> = AppConfigStore.preferencesFlow
+        .map { it.toBookExportSettings() }
+        .distinctUntilChanged()
+
+    override suspend fun update(transform: (BookExportSettings) -> BookExportSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, BookExportSettings::toPrefMap))
+    }
+}
+
+internal fun Preferences.toBookExportSettings() = BookExportSettings(
+    bookExportFileName = compatDsString(PreferKey.bookExportFileName),
+    episodeExportFileName = compatDsString(PreferKey.episodeExportFileName).orEmpty(),
+    exportCharset = compatDsString(PreferKey.exportCharset) ?: "UTF-8",
+    exportUseReplace = compatDsBoolean(PreferKey.exportUseReplace) ?: true,
+    exportToWebDav = compatDsBoolean(PreferKey.exportToWebDav) ?: false,
+    exportNoChapterName = compatDsBoolean(PreferKey.exportNoChapterName) ?: false,
+    enableCustomExport = compatDsBoolean(PreferKey.enableCustomExport) ?: false,
+    exportType = compatDsInt(PreferKey.exportType) ?: 0,
+    exportPictureFile = compatDsBoolean(PreferKey.exportPictureFile) ?: false,
+    parallelExportBook = compatDsBoolean(PreferKey.parallelExportBook) ?: false,
+)
+
+internal fun BookExportSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.bookExportFileName to bookExportFileName,
+    PreferKey.episodeExportFileName to episodeExportFileName,
+    PreferKey.exportCharset to exportCharset,
+    PreferKey.exportUseReplace to exportUseReplace,
+    PreferKey.exportToWebDav to exportToWebDav,
+    PreferKey.exportNoChapterName to exportNoChapterName,
+    PreferKey.enableCustomExport to enableCustomExport,
+    PreferKey.exportType to exportType,
+    PreferKey.exportPictureFile to exportPictureFile,
+    PreferKey.parallelExportBook to parallelExportBook,
+)

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

@@ -0,0 +1,112 @@
+package io.legado.app.data.repository
+
+import androidx.room.withTransaction
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.TagGroupRule
+import io.legado.app.domain.gateway.BookGroupMutationGateway
+import io.legado.app.domain.model.BookGroupUpdate
+import io.legado.app.domain.model.NewBookGroup
+import io.legado.app.domain.model.TagGroupRuleUpdate
+
+class BookGroupMutationRepository(
+    private val database: AppDatabase,
+    private val tagGroupRuleApplier: TagGroupRuleApplier,
+) : BookGroupMutationGateway {
+
+    override suspend fun addGroup(group: NewBookGroup) {
+        group.pattern?.takeIf(String::isNotBlank)?.let(::Regex)
+
+        database.withTransaction {
+            val groupDao = database.bookGroupDao
+            val groupId = groupDao.getUnusedId()
+            val bookGroup = BookGroup(
+                groupId = groupId,
+                groupName = group.groupName,
+                cover = group.cover,
+                bookSort = group.bookSort,
+                enableRefresh = group.enableRefresh,
+                isPrivate = group.isPrivate,
+                order = groupDao.maxOrder.plus(1),
+            )
+
+            if (groupDao.getByID(groupId) == null) {
+                database.bookDao.removeGroup(groupId)
+            }
+            groupDao.insert(bookGroup)
+
+            if (!group.pattern.isNullOrBlank()) {
+                database.tagGroupRuleDao.insert(
+                    TagGroupRule(
+                        groupName = group.groupName,
+                        pattern = group.pattern,
+                        order = database.tagGroupRuleDao.maxOrder,
+                    )
+                )
+                tagGroupRuleApplier.applyInCurrentTransaction()
+            }
+        }
+    }
+
+    override suspend fun saveGroup(
+        bookGroup: BookGroupUpdate,
+        ruleToSave: TagGroupRuleUpdate?,
+        ruleIdToDelete: Long?,
+    ) {
+        ruleToSave?.pattern?.let(::Regex)
+
+        database.withTransaction {
+            database.bookGroupDao.update(bookGroup.toEntity())
+            ruleToSave?.let { upsertTagGroupRule(it) }
+            ruleIdToDelete?.let { id ->
+                database.tagGroupRuleDao.getById(id)?.let { database.tagGroupRuleDao.delete(it) }
+            }
+            if (ruleToSave != null || ruleIdToDelete != null) {
+                tagGroupRuleApplier.applyInCurrentTransaction()
+            }
+        }
+    }
+
+    override suspend fun saveTagGroupRule(rule: TagGroupRuleUpdate) {
+        Regex(rule.pattern)
+        database.withTransaction {
+            upsertTagGroupRule(rule)
+            tagGroupRuleApplier.applyInCurrentTransaction()
+        }
+    }
+
+    override suspend fun deleteTagGroupRule(ruleId: Long) {
+        database.withTransaction {
+            database.tagGroupRuleDao.getById(ruleId)?.let { database.tagGroupRuleDao.delete(it) }
+            tagGroupRuleApplier.applyInCurrentTransaction()
+        }
+    }
+
+    private suspend fun upsertTagGroupRule(rule: TagGroupRuleUpdate) {
+        val ruleDao = database.tagGroupRuleDao
+        val existing = ruleDao.getByGroupName(rule.groupName)
+        if (existing != null) {
+            ruleDao.update(rule.toEntity(id = existing.id))
+        } else {
+            ruleDao.insert(rule.toEntity())
+        }
+    }
+
+    private fun BookGroupUpdate.toEntity() = BookGroup(
+        groupId = groupId,
+        groupName = groupName,
+        cover = cover,
+        order = order,
+        enableRefresh = enableRefresh,
+        show = show,
+        bookSort = bookSort,
+        isPrivate = isPrivate,
+    )
+
+    private fun TagGroupRuleUpdate.toEntity(id: Long = this.id) = TagGroupRule(
+        id = id,
+        pattern = pattern,
+        groupName = groupName,
+        order = order,
+    )
+}

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

@@ -1,6 +1,5 @@
 package io.legado.app.data.repository
 
-import androidx.lifecycle.asFlow
 import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.entities.BookGroup
 import kotlinx.coroutines.flow.Flow
@@ -16,7 +15,7 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) {
     }
 
     fun flowShow(): Flow<List<BookGroup>> {
-        return bookGroupDao.show.asFlow()
+        return bookGroupDao.flowShow()
     }
 
     suspend fun update(vararg bookGroup: BookGroup) {

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

@@ -47,6 +47,7 @@ class BookshelfSettingsRepository : BookshelfSettingsGateway {
                 BookshelfBooleanSetting.CoverShadow -> PreferKey.bookshelfCoverShadow
                 BookshelfBooleanSetting.SearchActionDirectToSearch -> PreferKey.bookshelfSearchActionDirectToSearch
                 BookshelfBooleanSetting.AutoRefresh -> PreferKey.autoRefresh
+                BookshelfBooleanSetting.HideEmptyGroups -> PreferKey.hideEmptyGroups
             } to update.value
             is BookshelfSettingsUpdate.IntValue -> when (update.setting) {
                 BookshelfIntSetting.BookGroupStyle -> PreferKey.bookGroupStyle
@@ -82,6 +83,7 @@ class BookshelfSettingsRepository : BookshelfSettingsGateway {
 
 private fun Preferences.toBookshelfSettings() = BookshelfSettings(
     bookGroupStyle = compatDsInt(PreferKey.bookGroupStyle) ?: 0,
+    hideEmptyGroups = compatDsBoolean(PreferKey.hideEmptyGroups) ?: false,
     bookshelfSort = compatDsInt(PreferKey.bookshelfSort) ?: 0,
     bookshelfSortOrder = compatDsInt(PreferKey.bookshelfSortOrder) ?: 1,
     showUnread = compatDsBoolean(PreferKey.showUnread) ?: true,

+ 82 - 0
app/src/main/java/io/legado/app/data/repository/ChangeSourceSettingsRepository.kt

@@ -0,0 +1,82 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.model.settings.ChangeSourceSettings
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class ChangeSourceSettingsRepository : ChangeSourceSettingsGateway {
+    override val currentSettings: ChangeSourceSettings
+        get() = AppConfigStore.preferences.toChangeSourceSettings()
+
+    override val settings: Flow<ChangeSourceSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toChangeSourceSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(transform: (ChangeSourceSettings) -> ChangeSourceSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, ChangeSourceSettings::toPrefMap))
+    }
+
+    override suspend fun setMigrationOptions(options: ChangeSourceMigrationOptions) {
+        AppConfigStore.putAll(
+            mapOf(
+                KEY_MIGRATE_CHAPTERS to options.migrateChapters,
+                KEY_MIGRATE_READING_PROGRESS to options.migrateReadingProgress,
+                KEY_MIGRATE_GROUP to options.migrateGroup,
+                KEY_MIGRATE_COVER to options.migrateCover,
+                KEY_MIGRATE_CATEGORY to options.migrateCategory,
+                KEY_MIGRATE_REMARK to options.migrateRemark,
+                KEY_MIGRATE_READ_CONFIG to options.migrateReadConfig,
+                KEY_DELETE_DOWNLOADED_CHAPTERS to options.deleteDownloadedChapters,
+            )
+        )
+    }
+}
+
+internal fun Preferences.toChangeSourceSettings() = ChangeSourceSettings(
+    searchScope = compatDsString(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE.name).orEmpty(),
+    checkAuthor = compatDsBoolean(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR.name) ?: false,
+    loadInfo = compatDsBoolean(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO.name) ?: false,
+    loadToc = compatDsBoolean(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC.name) ?: false,
+    loadWordCount = compatDsBoolean(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT.name) ?: false,
+    migrateChapters = compatDsBoolean(KEY_MIGRATE_CHAPTERS) ?: true,
+    migrateReadingProgress = compatDsBoolean(KEY_MIGRATE_READING_PROGRESS) ?: true,
+    migrateGroup = compatDsBoolean(KEY_MIGRATE_GROUP) ?: true,
+    migrateCover = compatDsBoolean(KEY_MIGRATE_COVER) ?: true,
+    migrateCategory = compatDsBoolean(KEY_MIGRATE_CATEGORY) ?: true,
+    migrateRemark = compatDsBoolean(KEY_MIGRATE_REMARK) ?: true,
+    migrateReadConfig = compatDsBoolean(KEY_MIGRATE_READ_CONFIG) ?: true,
+    deleteDownloadedChapters = compatDsBoolean(KEY_DELETE_DOWNLOADED_CHAPTERS) ?: false,
+)
+
+internal fun ChangeSourceSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE.name to searchScope,
+    LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR.name to checkAuthor,
+    LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO.name to loadInfo,
+    LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC.name to loadToc,
+    LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT.name to loadWordCount,
+    KEY_MIGRATE_CHAPTERS to migrateChapters,
+    KEY_MIGRATE_READING_PROGRESS to migrateReadingProgress,
+    KEY_MIGRATE_GROUP to migrateGroup,
+    KEY_MIGRATE_COVER to migrateCover,
+    KEY_MIGRATE_CATEGORY to migrateCategory,
+    KEY_MIGRATE_REMARK to migrateRemark,
+    KEY_MIGRATE_READ_CONFIG to migrateReadConfig,
+    KEY_DELETE_DOWNLOADED_CHAPTERS to deleteDownloadedChapters,
+)
+
+private const val KEY_MIGRATE_CHAPTERS = "migrateChapters"
+private const val KEY_MIGRATE_READING_PROGRESS = "migrateReadingProgress"
+private const val KEY_MIGRATE_GROUP = "migrateGroup"
+private const val KEY_MIGRATE_COVER = "migrateCover"
+private const val KEY_MIGRATE_CATEGORY = "migrateCategory"
+private const val KEY_MIGRATE_REMARK = "migrateRemark"
+private const val KEY_MIGRATE_READ_CONFIG = "migrateReadConfig"
+private const val KEY_DELETE_DOWNLOADED_CHAPTERS = "deleteDownloadedChapters"

+ 14 - 5
app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt

@@ -5,13 +5,14 @@ import androidx.annotation.Keep
 import com.google.gson.annotations.SerializedName
 import io.legado.app.R
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.PreferKey
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.domain.gateway.CoverAlbumGateway
 import io.legado.app.domain.model.CoverAlbum
 import io.legado.app.domain.model.CoverAlbumImage
 import io.legado.app.domain.model.CoverAlbumImageInput
 import io.legado.app.domain.model.CoverAlbumSelection
-import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.utils.GSON
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -238,8 +239,12 @@ class CoverAlbumRepository(
                 LocalPreferencesKeys.COVER_ALBUM_MIGRATED,
                 false,
             ).first()
-            val legacyLight = CoverConfig.defaultCover.toExistingFiles()
-            val legacyDark = CoverConfig.defaultCoverDark.toExistingFiles()
+            val legacyLight = AppConfigStore.getString(PreferKey.defaultCover)
+                .orEmpty()
+                .toExistingFiles()
+            val legacyDark = AppConfigStore.getString(PreferKey.defaultCoverDark)
+                .orEmpty()
+                .toExistingFiles()
             val needsRecovery = _albums.value.isEmpty() &&
                 _selection.value.albumId == null &&
                 (legacyLight.isNotEmpty() || legacyDark.isNotEmpty())
@@ -287,8 +292,12 @@ class CoverAlbumRepository(
     }
 
     private fun syncLegacyCoverPaths() {
-        CoverConfig.defaultCover = selectedImagePaths(isDark = false).joinToString(",")
-        CoverConfig.defaultCoverDark = selectedImagePaths(isDark = true).joinToString(",")
+        AppConfigStore.putAll(
+            mapOf(
+                PreferKey.defaultCover to selectedImagePaths(isDark = false).joinToString(","),
+                PreferKey.defaultCoverDark to selectedImagePaths(isDark = true).joinToString(","),
+            )
+        )
     }
 
     private fun copyImages(

+ 189 - 153
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -17,17 +17,13 @@ import io.legado.app.domain.gateway.ThemeStringSetting
 import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.domain.model.settings.AppShellSettings
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-import io.legado.app.domain.gateway.DownloadCacheSettingsUpdate
 import io.legado.app.domain.model.settings.DownloadCacheSettings
 import io.legado.app.domain.gateway.CoverSettingsGateway
-import io.legado.app.domain.gateway.CoverSettingsUpdate
 import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.domain.gateway.LabSettingsGateway
-import io.legado.app.domain.gateway.LabSettingsUpdate
 import io.legado.app.domain.gateway.TranslationSettingsGateway
-import io.legado.app.domain.gateway.TranslationSettingsUpdate
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.domain.model.settings.LabSettings
 import io.legado.app.domain.model.settings.TranslationSettings
@@ -185,154 +181,65 @@ class ThemeSettingsRepository : ThemeSettingsGateway {
 }
 
 class DownloadCacheSettingsRepository : DownloadCacheSettingsGateway {
+    override val currentSettings: DownloadCacheSettings
+        get() = AppConfigStore.preferences.toDownloadCacheSettings()
+
     override val settings: Flow<DownloadCacheSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            DownloadCacheSettings(
-                bitmapCacheSize = preferences.compatDsInt(PreferKey.bitmapCacheSize) ?: 50,
-                imageRetainNum = preferences.compatDsInt(PreferKey.imageRetainNum) ?: 0,
-                preDownloadNum = preferences.compatDsInt(PreferKey.preDownloadNum) ?: 10,
-                threadCount = preferences.compatDsInt(PreferKey.threadCount) ?: 16,
-                cacheBookThreadCount =
-                    preferences.compatDsInt(PreferKey.cacheBookThreadCount) ?: 16,
-                userAgent = preferences.compatDsString(PreferKey.userAgent).orEmpty().ifBlank {
-                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
-                        "AppleWebKit/537.36 (KHTML, like Gecko) " +
-                        "Chrome/${BuildConfig.Cronet_Main_Version} Safari/537.36"
-                },
-                cronetEnabled = preferences.compatDsBoolean(PreferKey.cronet) ?: false,
-            )
-        }
+        .map { it.toDownloadCacheSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: DownloadCacheSettingsUpdate) {
-        val (key, value) = when (update) {
-            is DownloadCacheSettingsUpdate.BitmapCacheSize -> PreferKey.bitmapCacheSize to update.value
-            is DownloadCacheSettingsUpdate.ImageRetainNum -> PreferKey.imageRetainNum to update.value
-            is DownloadCacheSettingsUpdate.PreDownloadNum -> PreferKey.preDownloadNum to update.value
-            is DownloadCacheSettingsUpdate.ThreadCount -> PreferKey.threadCount to update.value
-            is DownloadCacheSettingsUpdate.CacheBookThreadCount ->
-                PreferKey.cacheBookThreadCount to update.value
-            is DownloadCacheSettingsUpdate.UserAgent -> PreferKey.userAgent to update.value
-            is DownloadCacheSettingsUpdate.CronetEnabled -> PreferKey.cronet to update.value
-        }
-        AppConfigStore.putAll(mapOf(key to value))
+    override suspend fun update(transform: (DownloadCacheSettings) -> DownloadCacheSettings) {
+        AppConfigStore.putAllAndAwait(
+            currentSettings.diffPrefMap(transform, DownloadCacheSettings::toPrefMap)
+        )
     }
 }
 
 class CoverSettingsRepository : CoverSettingsGateway {
+    override val currentSettings: CoverSettings
+        get() = AppConfigStore.preferences.toCoverSettings()
+
     override val settings: Flow<CoverSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            CoverSettings(
-                loadOnlyOnWifi = preferences.compatDsBoolean(PreferKey.loadCoverOnlyWifi) ?: false,
-                useDefaultCover = preferences.compatDsBoolean(PreferKey.useDefaultCover) ?: false,
-                showShadow = preferences.compatDsBoolean(PreferKey.coverShowShadow) ?: false,
-                showStroke = preferences.compatDsBoolean(PreferKey.coverShowStroke) ?: true,
-                useDefaultColor = preferences.compatDsBoolean(PreferKey.coverDefaultColor) ?: true,
-                textColor = preferences.compatDsInt(PreferKey.coverTextColor) ?: -16777216,
-                shadowColor = preferences.compatDsInt(PreferKey.coverShadowColor) ?: -16777216,
-                showName = preferences.compatDsBoolean(PreferKey.coverShowName) ?: true,
-                showAuthor = preferences.compatDsBoolean(PreferKey.coverShowAuthor) ?: true,
-                textColorDark = preferences.compatDsInt(PreferKey.coverTextColorN) ?: -1,
-                shadowColorDark = preferences.compatDsInt(PreferKey.coverShadowColorN) ?: -1,
-                showNameDark = preferences.compatDsBoolean(PreferKey.coverShowNameN) ?: true,
-                showAuthorDark = preferences.compatDsBoolean(PreferKey.coverShowAuthorN) ?: true,
-                infoOrientation = preferences.compatDsString(PreferKey.coverInfoOrientation) ?: "0",
-                exploreFilterState = preferences.compatDsInt(PreferKey.exploreFilterState) ?: 0,
-                defaultCover = preferences.compatDsString(PreferKey.defaultCover).orEmpty(),
-                defaultCoverDark = preferences.compatDsString(PreferKey.defaultCoverDark).orEmpty(),
-            )
-        }
+        .map { it.toCoverSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: CoverSettingsUpdate) {
-        val (key, value) = when (update) {
-            is CoverSettingsUpdate.LoadOnlyOnWifi -> PreferKey.loadCoverOnlyWifi to update.value
-            is CoverSettingsUpdate.UseDefaultCover -> PreferKey.useDefaultCover to update.value
-            is CoverSettingsUpdate.ShowShadow -> PreferKey.coverShowShadow to update.value
-            is CoverSettingsUpdate.ShowStroke -> PreferKey.coverShowStroke to update.value
-            is CoverSettingsUpdate.UseDefaultColor -> PreferKey.coverDefaultColor to update.value
-            is CoverSettingsUpdate.TextColor ->
-                (if (update.dark) PreferKey.coverTextColorN else PreferKey.coverTextColor) to update.value
-            is CoverSettingsUpdate.ShadowColor ->
-                (if (update.dark) PreferKey.coverShadowColorN else PreferKey.coverShadowColor) to update.value
-            is CoverSettingsUpdate.ShowName ->
-                (if (update.dark) PreferKey.coverShowNameN else PreferKey.coverShowName) to update.value
-            is CoverSettingsUpdate.ShowAuthor ->
-                (if (update.dark) PreferKey.coverShowAuthorN else PreferKey.coverShowAuthor) to update.value
-            is CoverSettingsUpdate.InfoOrientation -> PreferKey.coverInfoOrientation to update.value
-            is CoverSettingsUpdate.ExploreFilterState -> PreferKey.exploreFilterState to update.value
-        }
-        AppConfigStore.putAll(mapOf(key to value))
+    override suspend fun update(transform: (CoverSettings) -> CoverSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, CoverSettings::toPrefMap))
     }
 }
 
 class LabSettingsRepository : LabSettingsGateway {
+    override val currentSettings: LabSettings
+        get() = AppConfigStore.preferences.toLabSettings()
+
     override val settings: Flow<LabSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            LabSettings(
-                enabled = preferences.compatDsBoolean(PreferKey.labEnabled) ?: false,
-                eInkDisplay = preferences.compatDsBoolean(PreferKey.labEInkDisplay) ?: false,
-                eyeProtection = preferences.compatDsBoolean(PreferKey.labEyeProtection) ?: false,
-            )
-        }
+        .map { it.toLabSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: LabSettingsUpdate) {
-        val (key, value) = when (update) {
-            is LabSettingsUpdate.Enabled -> PreferKey.labEnabled to update.value
-            is LabSettingsUpdate.EInkDisplay -> PreferKey.labEInkDisplay to update.value
-            is LabSettingsUpdate.EyeProtection -> PreferKey.labEyeProtection to update.value
-        }
-        AppConfigStore.putAll(mapOf(key to value))
+    override suspend fun update(transform: (LabSettings) -> LabSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, LabSettings::toPrefMap))
     }
 }
 
 class TranslationSettingsRepository : TranslationSettingsGateway {
+    override val currentSettings: TranslationSettings
+        get() = AppConfigStore.preferences.toTranslationSettings()
+
     override val settings: Flow<TranslationSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            val storedProvider = preferences.compatDsString(PreferKey.llmProvider)
-                ?: TranslationConstants.PROVIDER_GOOGLE
-            TranslationSettings(
-                provider = if (storedProvider == TranslationConstants.PROVIDER_OPENAI) {
-                    TranslationConstants.PROVIDER_APP_AI
-                } else {
-                    storedProvider
-                },
-                targetLanguage = preferences.compatDsString(PreferKey.llmTargetLanguage) ?: "zh",
-                maxCharsPerChunk = preferences.compatDsInt(PreferKey.llmMaxCharsPerChunk) ?: 10000,
-            )
-        }
+        .map { it.toTranslationSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: TranslationSettingsUpdate) {
-        val (key, value) = when (update) {
-            is TranslationSettingsUpdate.Provider -> PreferKey.llmProvider to update.value
-            is TranslationSettingsUpdate.TargetLanguage -> PreferKey.llmTargetLanguage to update.value
-            is TranslationSettingsUpdate.MaxCharsPerChunk -> PreferKey.llmMaxCharsPerChunk to update.value
-        }
-        AppConfigStore.putAll(mapOf(key to value))
+    override suspend fun update(transform: (TranslationSettings) -> TranslationSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, TranslationSettings::toPrefMap))
     }
 }
 
 class BackupSettingsRepository : BackupSettingsGateway {
+    override val currentSettings: BackupSettings
+        get() = AppConfigStore.preferences.toBackupSettings()
+
     override val settings: Flow<BackupSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            BackupSettings(
-                webDavUrl = preferences.compatDsString(PreferKey.webDavUrl).orEmpty(),
-                webDavAccount = preferences.compatDsString(PreferKey.webDavAccount).orEmpty(),
-                webDavPassword = preferences.compatDsString(PreferKey.webDavPassword).orEmpty(),
-                webDavDir = preferences.compatDsString(PreferKey.webDavDir) ?: "legado",
-                webDavDeviceName = preferences.compatDsString(PreferKey.webDavDeviceName).orEmpty(),
-                syncBookProgress = preferences.compatDsBoolean(PreferKey.syncBookProgress) ?: true,
-                syncBookProgressPlus =
-                    preferences.compatDsBoolean(PreferKey.syncBookProgressPlus) ?: false,
-                autoCheckNewBackup =
-                    preferences.compatDsBoolean(PreferKey.autoCheckNewBackup) ?: true,
-                onlyLatestBackup = preferences.compatDsBoolean(PreferKey.onlyLatestBackup) ?: true,
-                backupSyncMode = preferences.compatDsString(PreferKey.backupSyncMode) ?: "both",
-                backupPath = preferences.compatDsString(PreferKey.backupPath),
-            )
-        }
+        .map { it.toBackupSettings() }
         .distinctUntilChanged()
 
     override suspend fun update(update: BackupSettingsUpdate) = updateAll(listOf(update))
@@ -367,7 +274,122 @@ class BackupSettingsRepository : BackupSettingsGateway {
     }
 }
 
-private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
+internal fun Preferences.toDownloadCacheSettings(): DownloadCacheSettings =
+    DownloadCacheSettings(
+        bitmapCacheSize = compatDsInt(PreferKey.bitmapCacheSize) ?: 50,
+        imageRetainNum = compatDsInt(PreferKey.imageRetainNum) ?: 0,
+        preDownloadNum = compatDsInt(PreferKey.preDownloadNum) ?: 10,
+        threadCount = compatDsInt(PreferKey.threadCount) ?: 16,
+        cacheBookThreadCount = compatDsInt(PreferKey.cacheBookThreadCount) ?: 16,
+        userAgent = compatDsString(PreferKey.userAgent).orEmpty().ifBlank {
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+                "AppleWebKit/537.36 (KHTML, like Gecko) " +
+                "Chrome/${BuildConfig.Cronet_Main_Version} Safari/537.36"
+        },
+        cronetEnabled = compatDsBoolean(PreferKey.cronet) ?: false,
+    )
+
+internal fun DownloadCacheSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.bitmapCacheSize to bitmapCacheSize,
+    PreferKey.imageRetainNum to imageRetainNum,
+    PreferKey.preDownloadNum to preDownloadNum,
+    PreferKey.threadCount to threadCount,
+    PreferKey.cacheBookThreadCount to cacheBookThreadCount,
+    PreferKey.userAgent to userAgent,
+    PreferKey.cronet to cronetEnabled,
+)
+
+internal fun Preferences.toCoverSettings(): CoverSettings = CoverSettings(
+    loadOnlyOnWifi = compatDsBoolean(PreferKey.loadCoverOnlyWifi) ?: false,
+    useDefaultCover = compatDsBoolean(PreferKey.useDefaultCover) ?: false,
+    showShadow = compatDsBoolean(PreferKey.coverShowShadow) ?: false,
+    showStroke = compatDsBoolean(PreferKey.coverShowStroke) ?: true,
+    useDefaultColor = compatDsBoolean(PreferKey.coverDefaultColor) ?: true,
+    textColor = compatDsInt(PreferKey.coverTextColor) ?: -16777216,
+    shadowColor = compatDsInt(PreferKey.coverShadowColor) ?: -16777216,
+    showName = compatDsBoolean(PreferKey.coverShowName) ?: true,
+    showAuthor = compatDsBoolean(PreferKey.coverShowAuthor) ?: true,
+    textColorDark = compatDsInt(PreferKey.coverTextColorN) ?: -1,
+    shadowColorDark = compatDsInt(PreferKey.coverShadowColorN) ?: -1,
+    showNameDark = compatDsBoolean(PreferKey.coverShowNameN) ?: true,
+    showAuthorDark = compatDsBoolean(PreferKey.coverShowAuthorN) ?: true,
+    infoOrientation = compatDsString(PreferKey.coverInfoOrientation) ?: "0",
+    exploreFilterState = compatDsInt(PreferKey.exploreFilterState) ?: 0,
+    defaultCover = compatDsString(PreferKey.defaultCover).orEmpty(),
+    defaultCoverDark = compatDsString(PreferKey.defaultCoverDark).orEmpty(),
+)
+
+internal fun CoverSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.loadCoverOnlyWifi to loadOnlyOnWifi,
+    PreferKey.useDefaultCover to useDefaultCover,
+    PreferKey.coverShowShadow to showShadow,
+    PreferKey.coverShowStroke to showStroke,
+    PreferKey.coverDefaultColor to useDefaultColor,
+    PreferKey.coverTextColor to textColor,
+    PreferKey.coverShadowColor to shadowColor,
+    PreferKey.coverShowName to showName,
+    PreferKey.coverShowAuthor to showAuthor,
+    PreferKey.coverTextColorN to textColorDark,
+    PreferKey.coverShadowColorN to shadowColorDark,
+    PreferKey.coverShowNameN to showNameDark,
+    PreferKey.coverShowAuthorN to showAuthorDark,
+    PreferKey.coverInfoOrientation to infoOrientation,
+    PreferKey.exploreFilterState to exploreFilterState,
+    PreferKey.defaultCover to defaultCover,
+    PreferKey.defaultCoverDark to defaultCoverDark,
+)
+
+internal fun Preferences.toLabSettings(): LabSettings = LabSettings(
+    enabled = compatDsBoolean(PreferKey.labEnabled) ?: false,
+    eInkDisplay = compatDsBoolean(PreferKey.labEInkDisplay) ?: false,
+    eyeProtection = compatDsBoolean(PreferKey.labEyeProtection) ?: false,
+)
+
+internal fun LabSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.labEnabled to enabled,
+    PreferKey.labEInkDisplay to eInkDisplay,
+    PreferKey.labEyeProtection to eyeProtection,
+)
+
+internal fun Preferences.toTranslationSettings(): TranslationSettings {
+    val storedProvider = compatDsString(PreferKey.llmProvider)
+        ?: TranslationConstants.PROVIDER_GOOGLE
+    return TranslationSettings(
+        provider = if (storedProvider == TranslationConstants.PROVIDER_OPENAI) {
+            TranslationConstants.PROVIDER_APP_AI
+        } else {
+            storedProvider
+        },
+        targetLanguage = compatDsString(PreferKey.llmTargetLanguage) ?: "zh",
+        maxCharsPerChunk = compatDsInt(PreferKey.llmMaxCharsPerChunk) ?: 10000,
+        concurrentChunks = compatDsInt(PreferKey.llmConcurrentChunks) ?: 1,
+        retryCount = compatDsInt(PreferKey.llmRetryCount) ?: 2,
+    )
+}
+
+internal fun TranslationSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.llmProvider to provider,
+    PreferKey.llmTargetLanguage to targetLanguage,
+    PreferKey.llmMaxCharsPerChunk to maxCharsPerChunk,
+    PreferKey.llmConcurrentChunks to concurrentChunks,
+    PreferKey.llmRetryCount to retryCount,
+)
+
+internal fun Preferences.toBackupSettings(): BackupSettings = BackupSettings(
+    webDavUrl = compatDsString(PreferKey.webDavUrl).orEmpty(),
+    webDavAccount = compatDsString(PreferKey.webDavAccount).orEmpty(),
+    webDavPassword = compatDsString(PreferKey.webDavPassword).orEmpty(),
+    webDavDir = compatDsString(PreferKey.webDavDir) ?: "legado",
+    webDavDeviceName = compatDsString(PreferKey.webDavDeviceName).orEmpty(),
+    syncBookProgress = compatDsBoolean(PreferKey.syncBookProgress) ?: true,
+    syncBookProgressPlus = compatDsBoolean(PreferKey.syncBookProgressPlus) ?: false,
+    autoCheckNewBackup = compatDsBoolean(PreferKey.autoCheckNewBackup) ?: true,
+    onlyLatestBackup = compatDsBoolean(PreferKey.onlyLatestBackup) ?: true,
+    backupSyncMode = compatDsString(PreferKey.backupSyncMode) ?: "both",
+    backupPath = compatDsString(PreferKey.backupPath),
+)
+
+internal fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
     themeMode = compatDsString(PreferKey.themeMode) ?: "0",
     fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
     composeEngine = compatDsString(PreferKey.composeEngine) ?: "material",
@@ -395,7 +417,7 @@ private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSetting
     launcherIcon = compatDsString(PreferKey.launcherIcon) ?: "ic_launcher",
 )
 
-private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
+internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     appTheme = compatDsString(PreferKey.appTheme) ?: "0",
     useMiuixMonet = compatDsBoolean(PreferKey.useMiuixMonet) ?: false,
     isPureBlack = compatDsBoolean(PreferKey.pureBlack) ?: false,
@@ -435,6 +457,7 @@ private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
         compatDsString(PreferKey.bookInfoNetworkCoverBackground) ?: "on",
     bookInfoDefaultCoverBackground =
         compatDsString(PreferKey.bookInfoDefaultCoverBackground) ?: "on",
+    bookInfoInputColor = compatDsInt(PreferKey.bookInfoInputColor) ?: 0,
     backgroundImageLight = compatDsString(PreferKey.bgImage),
     backgroundImageDark = compatDsString(PreferKey.bgImageN),
     backgroundImageBlurring = compatDsInt(PreferKey.bgImageBlurring) ?: 0,
@@ -455,35 +478,43 @@ private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     customTagColorsJson = compatDsString(PreferKey.customTagColors),
 )
 
+internal fun Preferences.toOtherSettings(): OtherSettings {
+    val rawSourceEditMaxLine = compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
+    return OtherSettings(
+        updateToVariant = compatDsString(PreferKey.updateToVariant) ?: "official_version",
+        autoCheckUpdateOnStart = compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
+        webServiceAutoStart = compatDsBoolean(PreferKey.webServiceAutoStart) ?: false,
+        autoRefresh = compatDsBoolean(PreferKey.autoRefresh) ?: false,
+        defaultToRead = compatDsBoolean(PreferKey.defaultToRead) ?: false,
+        notificationsPost = compatDsBoolean(PreferKey.notificationsPost) ?: true,
+        ignoreBatteryPermission = compatDsBoolean(PreferKey.ignoreBatteryPermission) ?: true,
+        firebaseEnable = compatDsBoolean(PreferKey.firebaseEnable) ?: true,
+        defaultBookTreeUri = compatDsString(PreferKey.defaultBookTreeUri),
+        antiAlias = compatDsBoolean(PreferKey.antiAlias) ?: false,
+        replaceEnableDefault = compatDsBoolean(PreferKey.replaceEnableDefault) ?: true,
+        autoClearExpired = compatDsBoolean(PreferKey.autoClearExpired) ?: true,
+        showAddToShelfAlert = compatDsBoolean(PreferKey.showAddToShelfAlert) ?: true,
+        showMangaUi = compatDsBoolean(PreferKey.showMangaUi) ?: true,
+        webServiceWakeLock = compatDsBoolean(PreferKey.webServiceWakeLock) ?: false,
+        sourceEditMaxLine = rawSourceEditMaxLine.takeIf { it >= 10 } ?: Int.MAX_VALUE,
+        webPort = compatDsInt(PreferKey.webPort) ?: 1122,
+        processText = compatDsBoolean(PreferKey.processText) ?: true,
+        recordLog = compatDsBoolean(PreferKey.recordLog) ?: false,
+        recordHeapDump = compatDsBoolean(PreferKey.recordHeapDump) ?: false,
+        audioPlayUseWakeLock = compatDsBoolean(PreferKey.audioPlayWakeLock) ?: false,
+        importKeepName = compatDsBoolean(PreferKey.importKeepName) ?: false,
+        importKeepGroup = compatDsBoolean(PreferKey.importKeepGroup) ?: false,
+        importKeepEnable = compatDsBoolean(PreferKey.importKeepEnable) ?: false,
+        fontSort = compatDsInt(PreferKey.fontSort) ?: 0,
+    )
+}
+
 class OtherSettingsRepository : OtherSettingsGateway {
+    override val currentSettings: OtherSettings
+        get() = AppConfigStore.preferences.toOtherSettings()
+
     override val settings: Flow<OtherSettings> = AppConfigStore.preferencesFlow
-        .map { preferences ->
-            val rawSourceEditMaxLine = preferences.compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
-            OtherSettings(
-                updateToVariant = preferences.compatDsString(PreferKey.updateToVariant) ?: "official_version",
-                autoCheckUpdateOnStart =
-                    preferences.compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
-                webServiceAutoStart = preferences.compatDsBoolean(PreferKey.webServiceAutoStart) ?: false,
-                autoRefresh = preferences.compatDsBoolean(PreferKey.autoRefresh) ?: false,
-                defaultToRead = preferences.compatDsBoolean(PreferKey.defaultToRead) ?: false,
-                notificationsPost = preferences.compatDsBoolean(PreferKey.notificationsPost) ?: true,
-                ignoreBatteryPermission =
-                    preferences.compatDsBoolean(PreferKey.ignoreBatteryPermission) ?: true,
-                firebaseEnable = preferences.compatDsBoolean(PreferKey.firebaseEnable) ?: true,
-                defaultBookTreeUri = preferences.compatDsString(PreferKey.defaultBookTreeUri),
-                antiAlias = preferences.compatDsBoolean(PreferKey.antiAlias) ?: false,
-                replaceEnableDefault = preferences.compatDsBoolean(PreferKey.replaceEnableDefault) ?: true,
-                autoClearExpired = preferences.compatDsBoolean(PreferKey.autoClearExpired) ?: true,
-                showAddToShelfAlert = preferences.compatDsBoolean(PreferKey.showAddToShelfAlert) ?: true,
-                showMangaUi = preferences.compatDsBoolean(PreferKey.showMangaUi) ?: true,
-                webServiceWakeLock = preferences.compatDsBoolean(PreferKey.webServiceWakeLock) ?: false,
-                sourceEditMaxLine = rawSourceEditMaxLine.takeIf { it >= 10 } ?: Int.MAX_VALUE,
-                webPort = preferences.compatDsInt(PreferKey.webPort) ?: 1122,
-                processText = preferences.compatDsBoolean(PreferKey.processText) ?: true,
-                recordLog = preferences.compatDsBoolean(PreferKey.recordLog) ?: false,
-                recordHeapDump = preferences.compatDsBoolean(PreferKey.recordHeapDump) ?: false,
-            )
-        }
+        .map { it.toOtherSettings() }
         .distinctUntilChanged()
 
     override suspend fun update(update: OtherSettingsUpdate) {
@@ -506,7 +537,12 @@ class OtherSettingsRepository : OtherSettingsGateway {
             is OtherSettingsUpdate.ProcessText -> PreferKey.processText to update.value
             is OtherSettingsUpdate.RecordLog -> PreferKey.recordLog to update.value
             is OtherSettingsUpdate.RecordHeapDump -> PreferKey.recordHeapDump to update.value
+            is OtherSettingsUpdate.AudioPlayUseWakeLock -> PreferKey.audioPlayWakeLock to update.value
+            is OtherSettingsUpdate.ImportKeepName -> PreferKey.importKeepName to update.value
+            is OtherSettingsUpdate.ImportKeepGroup -> PreferKey.importKeepGroup to update.value
+            is OtherSettingsUpdate.ImportKeepEnable -> PreferKey.importKeepEnable to update.value
+            is OtherSettingsUpdate.FontSort -> PreferKey.fontSort to update.value
         }
-        AppConfigStore.putAll(mapOf(key to value))
+        AppConfigStore.putAllAndAwait(mapOf(key to value))
     }
 }

+ 145 - 0
app/src/main/java/io/legado/app/data/repository/HighlightRuleAssetTransfer.kt

@@ -0,0 +1,145 @@
+package io.legado.app.data.repository
+
+import android.content.Context
+import androidx.core.net.toUri
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.utils.isContentScheme
+import java.io.File
+
+internal object HighlightRuleAssetTransfer {
+
+    data class ExportResult(
+        val rules: List<HighlightRule>,
+        val files: List<File>,
+    )
+
+    fun prepareExport(
+        rules: List<HighlightRule>,
+        exportDir: File,
+        copyAsset: (source: String, target: File) -> Boolean,
+    ): ExportResult {
+        val files = ArrayList<File>()
+        val portableRules = rules.mapIndexed { index, rule ->
+            rule.copy(
+                bgImage = exportReference(
+                    reference = rule.bgImage,
+                    prefix = "highlight_rule_bg_$index",
+                    exportDir = exportDir,
+                    files = files,
+                    copyAsset = copyAsset,
+                ),
+                fontPath = exportReference(
+                    reference = rule.fontPath,
+                    prefix = "highlight_rule_font_$index",
+                    exportDir = exportDir,
+                    files = files,
+                    copyAsset = copyAsset,
+                ),
+            )
+        }
+        return ExportResult(portableRules, files)
+    }
+
+    fun restoreImport(
+        rules: List<HighlightRule>,
+        importDir: File,
+        backgroundDir: File,
+        fontDir: File,
+        isReadableBackgroundReference: (String) -> Boolean,
+        isReadableFontReference: (String) -> Boolean = isReadableBackgroundReference,
+    ): List<HighlightRule> {
+        return rules.map { rule ->
+            rule.copy(
+                bgImage = restoreReference(
+                    reference = rule.bgImage,
+                    importDir = importDir,
+                    targetDir = backgroundDir,
+                    isReadableReference = isReadableBackgroundReference,
+                ),
+                fontPath = restoreReference(
+                    reference = rule.fontPath,
+                    importDir = importDir,
+                    targetDir = fontDir,
+                    isReadableReference = isReadableFontReference,
+                ),
+            )
+        }
+    }
+
+    fun clearUnreadableReferences(
+        rules: List<HighlightRule>,
+        isReadableBackgroundReference: (String) -> Boolean,
+        isReadableFontReference: (String) -> Boolean,
+    ): List<HighlightRule> {
+        return rules.map { rule ->
+            rule.copy(
+                bgImage = rule.bgImage?.takeIf(isReadableBackgroundReference),
+                fontPath = rule.fontPath?.takeIf(isReadableFontReference),
+            )
+        }
+    }
+
+    private fun exportReference(
+        reference: String?,
+        prefix: String,
+        exportDir: File,
+        files: MutableList<File>,
+        copyAsset: (source: String, target: File) -> Boolean,
+    ): String? {
+        val source = reference?.takeIf { it.isNotBlank() } ?: return null
+        if (source.startsWith("assets://")) return source
+        val sourceName = File(source).name.ifBlank { "asset" }
+        val targetName = "${prefix}_${sourceName.safeFileName()}"
+        val target = File(exportDir, targetName)
+        return if (copyAsset(source, target)) {
+            files.add(target)
+            targetName
+        } else {
+            null
+        }
+    }
+
+    private fun restoreReference(
+        reference: String?,
+        importDir: File,
+        targetDir: File,
+        isReadableReference: (String) -> Boolean,
+    ): String? {
+        val source = reference?.takeIf { it.isNotBlank() } ?: return null
+        if (source.startsWith("assets://") || isReadableReference(source)) return source
+        val bundledFile = File(importDir, File(source).name)
+        if (!bundledFile.isFile) return null
+        targetDir.mkdirs()
+        val targetFile = File(targetDir, bundledFile.name)
+        if (!targetFile.exists() || targetFile.length() != bundledFile.length()) {
+            bundledFile.copyTo(targetFile, overwrite = true)
+        }
+        return targetFile.absolutePath
+    }
+
+    private fun String.safeFileName(): String {
+        return replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "asset" }
+    }
+}
+
+internal fun Context.isReadableHighlightBackground(path: String): Boolean {
+    if (path.isContentScheme()) {
+        return runCatching {
+            contentResolver.openAssetFileDescriptor(path.toUri(), "r")?.use { true } ?: false
+        }.getOrDefault(false)
+    }
+    if (File(path).isFile) return true
+    val assetPath = path.removePrefix("assets://").let {
+        if (it.startsWith("bg/")) it else "bg/$it"
+    }
+    return runCatching { assets.open(assetPath).use { true } }.getOrDefault(false)
+}
+
+internal fun Context.isReadableHighlightFont(path: String): Boolean {
+    if (path.isContentScheme()) {
+        return runCatching {
+            contentResolver.openAssetFileDescriptor(path.toUri(), "r")?.use { true } ?: false
+        }.getOrDefault(false)
+    }
+    return File(path).isFile
+}

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

@@ -29,11 +29,15 @@ class HighlightRuleRepository(
     )
 
     fun load(configName: String): List<HighlightRule> {
-        return dao.getAll().filter { it.matchesConfig(configName) }
+        return clearUnreadableReferences(
+            dao.getAll().filter { it.matchesConfig(configName) }
+        )
     }
 
     fun loadEnabled(configName: String): List<HighlightRule> {
-        return dao.getEnabled().filter { it.matchesConfig(configName) }
+        return clearUnreadableReferences(
+            dao.getEnabled().filter { it.matchesConfig(configName) }
+        )
     }
 
     /**
@@ -173,6 +177,18 @@ class HighlightRuleRepository(
         )
     }
 
+    private fun clearUnreadableReferences(rules: List<HighlightRule>): List<HighlightRule> {
+        val cleaned = HighlightRuleAssetTransfer.clearUnreadableReferences(
+            rules = rules,
+            isReadableBackgroundReference = context::isReadableHighlightBackground,
+            isReadableFontReference = context::isReadableHighlightFont,
+        )
+        rules.zip(cleaned).forEach { (original, updated) ->
+            if (original != updated) dao.update(updated)
+        }
+        return cleaned
+    }
+
     private fun normalizeTargetScope(value: Int, fallback: Int = HighlightRule.TARGET_ALL): Int {
         return when (value) {
             HighlightRule.TARGET_ALL,

+ 29 - 0
app/src/main/java/io/legado/app/data/repository/HomepageSettingsRepository.kt

@@ -0,0 +1,29 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.domain.gateway.HomepageSettingsGateway
+import io.legado.app.domain.model.settings.HomepageSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+private const val HOMEPAGE_SOURCE_HIDDEN = "homepageSourceHidden"
+
+class HomepageSettingsRepository : HomepageSettingsGateway {
+    override val currentSettings: HomepageSettings
+        get() = AppConfigStore.preferences.toHomepageSettings()
+
+    override val settings: Flow<HomepageSettings> = AppConfigStore.preferencesFlow
+        .map { it.toHomepageSettings() }
+        .distinctUntilChanged()
+
+    override suspend fun setHiddenSourceUrlsJson(value: String) {
+        AppConfigStore.putAll(mapOf(HOMEPAGE_SOURCE_HIDDEN to value))
+    }
+}
+
+internal fun Preferences.toHomepageSettings() = HomepageSettings(
+    hiddenSourceUrlsJson = compatDsString(HOMEPAGE_SOURCE_HIDDEN).orEmpty(),
+)

+ 40 - 0
app/src/main/java/io/legado/app/data/repository/ImportBookSettingsRepository.kt

@@ -0,0 +1,40 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.ImportBookSettingsGateway
+import io.legado.app.domain.model.settings.ImportBookSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsLong
+import io.legado.app.help.config.compatDsString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class ImportBookSettingsRepository : ImportBookSettingsGateway {
+    override val currentSettings: ImportBookSettings
+        get() = AppConfigStore.preferences.toImportBookSettings()
+
+    override val settings: Flow<ImportBookSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toImportBookSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(transform: (ImportBookSettings) -> ImportBookSettings) {
+        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, ImportBookSettings::toPrefMap))
+    }
+}
+
+internal fun Preferences.toImportBookSettings() = ImportBookSettings(
+    importBookPath = compatDsString(PreferKey.importBookPath),
+    bookImportFileName = compatDsString(PreferKey.bookImportFileName),
+    localBookImportSort = compatDsInt(PreferKey.localBookImportSort) ?: 0,
+    remoteServerId = compatDsLong(PreferKey.remoteServerId) ?: 0L,
+)
+
+internal fun ImportBookSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.importBookPath to importBookPath,
+    PreferKey.bookImportFileName to bookImportFileName,
+    PreferKey.localBookImportSort to localBookImportSort,
+    PreferKey.remoteServerId to remoteServerId,
+)

+ 147 - 0
app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt

@@ -0,0 +1,147 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.MangaSettingsGateway
+import io.legado.app.domain.gateway.MangaClickArea
+import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class MangaSettingsRepository : MangaSettingsGateway {
+    override val currentSettings: MangaSettings
+        get() = AppConfigStore.preferences.toMangaSettings()
+
+    override val settings: Flow<MangaSettings> = AppConfigStore.preferencesFlow
+        .map { it.toMangaSettings() }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: MangaSettingsUpdate) = updateAll(listOf(update))
+
+    override suspend fun updateAll(updates: List<MangaSettingsUpdate>) {
+        AppConfigStore.putAll(updates.toMangaPreferenceValues())
+    }
+}
+
+internal fun List<MangaSettingsUpdate>.toMangaPreferenceValues(): Map<String, Any?> =
+    buildMap {
+        this@toMangaPreferenceValues.forEach { update ->
+            when (update) {
+                    is MangaSettingsUpdate.ShowMangaUi -> put(PreferKey.showMangaUi, update.value)
+                    is MangaSettingsUpdate.DisableScale -> put(PreferKey.disableMangaScale, update.value)
+                    is MangaSettingsUpdate.DisableScrollAnimation ->
+                        put(PreferKey.disableMangaScrollAnimation, update.value)
+                    is MangaSettingsUpdate.DisableCrossFade ->
+                        put(PreferKey.disableMangaCrossFade, update.value)
+                    is MangaSettingsUpdate.ScrollMode -> put(PreferKey.mangaScrollMode, update.value)
+                    is MangaSettingsUpdate.PreDownloadNum ->
+                        put(PreferKey.mangaPreDownloadNum, update.value)
+                    is MangaSettingsUpdate.AutoPageSpeed ->
+                        put(PreferKey.mangaAutoPageSpeed, update.value)
+                    is MangaSettingsUpdate.FooterConfig -> put(PreferKey.mangaFooterConfig, update.value)
+                    is MangaSettingsUpdate.DisableClickScroll ->
+                        put(PreferKey.disableClickScroll, update.value)
+                    is MangaSettingsUpdate.LongClick -> put(PreferKey.mangaLongClick, update.value)
+                    is MangaSettingsUpdate.Background -> put(PreferKey.mangaBackground, update.value)
+                    is MangaSettingsUpdate.ColorFilter -> put(PreferKey.mangaColorFilter, update.value)
+                    is MangaSettingsUpdate.HideTitle -> put(PreferKey.hideMangaTitle, update.value)
+                    is MangaSettingsUpdate.EInk -> {
+                        put(PreferKey.enableMangaEInk, update.enabled)
+                        put(PreferKey.enableMangaGray, false)
+                        put(PreferKey.mangaEInkThreshold, update.threshold)
+                    }
+                    is MangaSettingsUpdate.Gray -> {
+                        put(PreferKey.enableMangaGray, update.enabled)
+                        put(PreferKey.enableMangaEInk, false)
+                    }
+                    is MangaSettingsUpdate.WebtoonSidePadding ->
+                        put(PreferKey.webtoonSidePaddingDp, update.value)
+                    is MangaSettingsUpdate.VolumeKeyPage ->
+                        put(PreferKey.mangaVolumeKeyPage, update.value)
+                    is MangaSettingsUpdate.ReverseVolumeKeyPage ->
+                        put(PreferKey.reverseVolumeKeyPage, update.value)
+                    is MangaSettingsUpdate.ClickAction -> put(
+                        when (update.area) {
+                            MangaClickArea.TL -> PreferKey.mangaClickActionTL
+                            MangaClickArea.TC -> PreferKey.mangaClickActionTC
+                            MangaClickArea.TR -> PreferKey.mangaClickActionTR
+                            MangaClickArea.ML -> PreferKey.mangaClickActionML
+                            MangaClickArea.MC -> PreferKey.mangaClickActionMC
+                            MangaClickArea.MR -> PreferKey.mangaClickActionMR
+                            MangaClickArea.BL -> PreferKey.mangaClickActionBL
+                            MangaClickArea.BC -> PreferKey.mangaClickActionBC
+                            MangaClickArea.BR -> PreferKey.mangaClickActionBR
+                        },
+                        update.value,
+                    )
+            }
+        }
+    }
+
+internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.showMangaUi to showMangaUi,
+    PreferKey.disableMangaScale to disableMangaScale,
+    PreferKey.disableMangaScrollAnimation to disableMangaScrollAnimation,
+    PreferKey.disableMangaCrossFade to disableMangaCrossFade,
+    PreferKey.mangaScrollMode to scrollMode,
+    PreferKey.mangaPreDownloadNum to preDownloadNum,
+    PreferKey.mangaAutoPageSpeed to autoPageSpeed,
+    PreferKey.mangaFooterConfig to footerConfig,
+    PreferKey.disableClickScroll to disableClickScroll,
+    PreferKey.mangaLongClick to longClick,
+    PreferKey.mangaBackground to background,
+    PreferKey.mangaColorFilter to colorFilter,
+    PreferKey.hideMangaTitle to hideTitle,
+    PreferKey.enableMangaEInk to enableEInk,
+    PreferKey.mangaEInkThreshold to eInkThreshold,
+    PreferKey.enableMangaGray to enableGray,
+    PreferKey.webtoonSidePaddingDp to webtoonSidePaddingDp,
+    PreferKey.mangaVolumeKeyPage to volumeKeyPage,
+    PreferKey.reverseVolumeKeyPage to reverseVolumeKeyPage,
+    PreferKey.mangaClickActionTL to clickActionTL,
+    PreferKey.mangaClickActionTC to clickActionTC,
+    PreferKey.mangaClickActionTR to clickActionTR,
+    PreferKey.mangaClickActionML to clickActionML,
+    PreferKey.mangaClickActionMC to clickActionMC,
+    PreferKey.mangaClickActionMR to clickActionMR,
+    PreferKey.mangaClickActionBL to clickActionBL,
+    PreferKey.mangaClickActionBC to clickActionBC,
+    PreferKey.mangaClickActionBR to clickActionBR,
+)
+
+internal fun Preferences.toMangaSettings(): MangaSettings = MangaSettings(
+    showMangaUi = compatDsBoolean(PreferKey.showMangaUi) ?: true,
+    disableMangaScale = compatDsBoolean(PreferKey.disableMangaScale) ?: true,
+    disableMangaScrollAnimation = compatDsBoolean(PreferKey.disableMangaScrollAnimation) ?: false,
+    disableMangaCrossFade = compatDsBoolean(PreferKey.disableMangaCrossFade) ?: false,
+    scrollMode = compatDsInt(PreferKey.mangaScrollMode) ?: 4,
+    preDownloadNum = compatDsInt(PreferKey.mangaPreDownloadNum) ?: 10,
+    autoPageSpeed = compatDsInt(PreferKey.mangaAutoPageSpeed) ?: 3,
+    footerConfig = compatDsString(PreferKey.mangaFooterConfig).orEmpty(),
+    disableClickScroll = compatDsBoolean(PreferKey.disableClickScroll) ?: false,
+    longClick = compatDsBoolean(PreferKey.mangaLongClick) ?: true,
+    background = compatDsInt(PreferKey.mangaBackground) ?: 0xFF000000.toInt(),
+    colorFilter = compatDsString(PreferKey.mangaColorFilter).orEmpty(),
+    hideTitle = compatDsBoolean(PreferKey.hideMangaTitle) ?: false,
+    enableEInk = compatDsBoolean(PreferKey.enableMangaEInk) ?: false,
+    eInkThreshold = compatDsInt(PreferKey.mangaEInkThreshold) ?: 150,
+    enableGray = compatDsBoolean(PreferKey.enableMangaGray) ?: false,
+    webtoonSidePaddingDp = compatDsInt(PreferKey.webtoonSidePaddingDp) ?: 0,
+    volumeKeyPage = compatDsBoolean(PreferKey.mangaVolumeKeyPage) ?: false,
+    reverseVolumeKeyPage = compatDsBoolean(PreferKey.reverseVolumeKeyPage) ?: false,
+    clickActionTL = compatDsInt(PreferKey.mangaClickActionTL) ?: -1,
+    clickActionTC = compatDsInt(PreferKey.mangaClickActionTC) ?: -1,
+    clickActionTR = compatDsInt(PreferKey.mangaClickActionTR) ?: 1,
+    clickActionML = compatDsInt(PreferKey.mangaClickActionML) ?: 2,
+    clickActionMC = compatDsInt(PreferKey.mangaClickActionMC) ?: 0,
+    clickActionMR = compatDsInt(PreferKey.mangaClickActionMR) ?: 1,
+    clickActionBL = compatDsInt(PreferKey.mangaClickActionBL) ?: 2,
+    clickActionBC = compatDsInt(PreferKey.mangaClickActionBC) ?: 1,
+    clickActionBR = compatDsInt(PreferKey.mangaClickActionBR) ?: 1,
+)

+ 170 - 0
app/src/main/java/io/legado/app/data/repository/OtherConfigAuxiliaryRepositories.kt

@@ -0,0 +1,170 @@
+package io.legado.app.data.repository
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import io.legado.app.R
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.gateway.CheckSourceSettings
+import io.legado.app.domain.gateway.CheckSourceSettingsGateway
+import io.legado.app.domain.gateway.DirectLinkRule
+import io.legado.app.domain.gateway.DirectLinkSettingsGateway
+import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.OtherConfigSystemGateway
+import io.legado.app.help.CacheManager
+import io.legado.app.help.DirectLinkUpload
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.webView.WebViewDataCleaner
+import io.legado.app.receiver.SharedReceiverActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+
+class CheckSourceSettingsRepository(
+    private val context: Context,
+) : CheckSourceSettingsGateway {
+
+    private val state = MutableStateFlow(loadSettings())
+
+    override val currentSettings: CheckSourceSettings
+        get() = state.value
+
+    override val settings = state.asStateFlow()
+
+    override suspend fun update(settings: CheckSourceSettings) {
+        require(settings.timeoutMillis > 0L)
+        require(settings.checkSearch || settings.checkDiscovery)
+
+        val previous = state.value
+        try {
+            withContext(Dispatchers.IO) { persistCache(settings) }
+            AppConfigStore.putAllAndAwait(
+                mapOf(PreferKey.checkSource to buildSummary(context, settings))
+            )
+        } catch (error: Exception) {
+            withContext(NonCancellable + Dispatchers.IO) { persistCache(previous) }
+            throw error
+        }
+        state.value = settings
+    }
+
+    private fun persistCache(settings: CheckSourceSettings) {
+        CacheManager.put(CHECK_SOURCE_TIMEOUT, settings.timeoutMillis)
+        CacheManager.put(CHECK_SEARCH, settings.checkSearch)
+        CacheManager.put(CHECK_DISCOVERY, settings.checkDiscovery)
+        CacheManager.put(CHECK_INFO, settings.checkInfo)
+        CacheManager.put(CHECK_CATEGORY, settings.checkCategory)
+        CacheManager.put(CHECK_CONTENT, settings.checkContent)
+    }
+
+    private fun loadSettings() = CheckSourceSettings(
+        timeoutMillis = CacheManager.getLong(CHECK_SOURCE_TIMEOUT) ?: 180_000L,
+        checkSearch = CacheManager.get(CHECK_SEARCH)?.toBoolean() ?: true,
+        checkDiscovery = CacheManager.get(CHECK_DISCOVERY)?.toBoolean() ?: true,
+        checkInfo = CacheManager.get(CHECK_INFO)?.toBoolean() ?: true,
+        checkCategory = CacheManager.get(CHECK_CATEGORY)?.toBoolean() ?: true,
+        checkContent = CacheManager.get(CHECK_CONTENT)?.toBoolean() ?: true,
+    )
+
+    private fun buildSummary(context: Context, settings: CheckSourceSettings): String {
+        val checkedItems = buildList {
+            if (settings.checkSearch) add(context.getString(R.string.search))
+            if (settings.checkDiscovery) add(context.getString(R.string.discovery))
+            if (settings.checkInfo) add(context.getString(R.string.source_tab_info))
+            if (settings.checkCategory) add(context.getString(R.string.chapter_list))
+            if (settings.checkContent) add(context.getString(R.string.main_body))
+        }.joinToString(separator = " ")
+        return context.getString(
+            R.string.check_source_config_summary,
+            (settings.timeoutMillis / 1_000L).toString(),
+            checkedItems,
+        )
+    }
+
+    private companion object {
+        const val CHECK_SOURCE_TIMEOUT = "checkSourceTimeout"
+        const val CHECK_SEARCH = "checkSearch"
+        const val CHECK_DISCOVERY = "checkDiscovery"
+        const val CHECK_INFO = "checkInfo"
+        const val CHECK_CATEGORY = "checkCategory"
+        const val CHECK_CONTENT = "checkContent"
+    }
+}
+
+class DirectLinkSettingsRepository : DirectLinkSettingsGateway {
+
+    override suspend fun loadRule(): DirectLinkRule = withContext(Dispatchers.IO) {
+        DirectLinkUpload.getRule().toDomain()
+    }
+
+    override suspend fun loadDefaultRules(): List<DirectLinkRule> =
+        withContext(Dispatchers.IO) { DirectLinkUpload.defaultRules.map { it.toDomain() } }
+
+    override suspend fun saveRule(rule: DirectLinkRule) = withContext(Dispatchers.IO) {
+        DirectLinkUpload.putConfig(rule.toLegacy())
+    }
+
+    override suspend fun testRule(rule: DirectLinkRule): String =
+        DirectLinkUpload.upLoad(
+            fileName = "test.json",
+            file = "{}",
+            contentType = "application/json",
+            rule = rule.toLegacy(),
+        )
+
+    private fun DirectLinkUpload.Rule.toDomain() = DirectLinkRule(
+        uploadUrl = uploadUrl,
+        downloadUrlRule = downloadUrlRule,
+        summary = summary,
+        compress = compress,
+    )
+
+    private fun DirectLinkRule.toLegacy() = DirectLinkUpload.Rule(
+        uploadUrl = uploadUrl,
+        downloadUrlRule = downloadUrlRule,
+        summary = summary,
+        compress = compress,
+    )
+}
+
+class LocalPasswordRepository : LocalPasswordGateway {
+
+    override suspend fun setPassword(password: String?) {
+        AppConfigStore.putAllAndAwait(
+            mapOf(LocalPreferencesKeys.PASSWORD.name to password.orEmpty())
+        )
+    }
+}
+
+class OtherConfigSystemRepository(
+    private val context: Context,
+) : OtherConfigSystemGateway {
+
+    private val componentName = ComponentName(
+        context,
+        SharedReceiverActivity::class.java.name,
+    )
+
+    override fun isProcessTextEnabled(): Boolean =
+        context.packageManager.getComponentEnabledSetting(componentName) !=
+            PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+
+    override suspend fun setProcessTextEnabled(enabled: Boolean) = withContext(Dispatchers.IO) {
+        context.packageManager.setComponentEnabledSetting(
+            componentName,
+            if (enabled) {
+                PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+            } else {
+                PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+            },
+            PackageManager.DONT_KILL_APP,
+        )
+    }
+
+    override suspend fun clearWebViewData() {
+        WebViewDataCleaner.clear(context)
+    }
+}

+ 50 - 14
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -10,8 +10,8 @@ import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsString
 import io.legado.app.help.config.compatDsValue
-import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
@@ -32,6 +32,9 @@ class ReadAloudSettingsRepository(
 
     override suspend fun update(update: ReadAloudSettingsUpdate) {
         when (update) {
+            is ReadAloudSettingsUpdate.TtsEngine -> setTtsEngine(update.value)
+            is ReadAloudSettingsUpdate.ParagraphInterval -> setTtsParagraphInterval(update.value)
+            is ReadAloudSettingsUpdate.AudioCacheCleanTime -> setAudioCacheCleanTime(update.value)
             is ReadAloudSettingsUpdate.IgnoreAudioFocus -> setIgnoreAudioFocus(update.value)
             is ReadAloudSettingsUpdate.MediaButtonOnExit -> setMediaButtonOnExit(update.value)
             is ReadAloudSettingsUpdate.ReadAloudByMediaButton -> setReadAloudByMediaButton(update.value)
@@ -45,11 +48,27 @@ class ReadAloudSettingsRepository(
             is ReadAloudSettingsUpdate.Timer -> setTtsTimer(update.value)
             is ReadAloudSettingsUpdate.FollowSystem -> setTtsFollowSys(update.value)
             is ReadAloudSettingsUpdate.SpeechRate -> setTtsSpeechRate(update.value)
+            is ReadAloudSettingsUpdate.ContentSelectSpeakMode ->
+                settingsRepository.putInt(PreferKey.contentSelectSpeakMod, update.value)
+            is ReadAloudSettingsUpdate.AudioPreDownloadNum ->
+                settingsRepository.putInt(PreferKey.audioPreDownloadNum, update.value)
         }
     }
 
+    suspend fun setTtsEngine(value: String?) {
+        AppConfigStore.putString(PreferKey.ttsEngine, value)
+    }
+
+    suspend fun setTtsParagraphInterval(value: Int) {
+        settingsRepository.putInt(PreferKey.ttsParagraphInterval, value)
+    }
+
+    suspend fun setAudioCacheCleanTime(value: Int) {
+        settingsRepository.putInt(PreferKey.audioCacheCleanTime, value)
+    }
+
     suspend fun setIgnoreAudioFocus(value: Boolean) {
-        ReadConfig.ignoreAudioFocus = value
+        settingsRepository.putBoolean(PreferKey.ignoreAudioFocus, value)
     }
 
     suspend fun setMediaButtonOnExit(value: Boolean) {
@@ -61,15 +80,15 @@ class ReadAloudSettingsRepository(
     }
 
     suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) {
-        ReadConfig.pauseReadAloudWhilePhoneCalls = value
+        settingsRepository.putBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
     }
 
     suspend fun setReadAloudWakeLock(value: Boolean) {
-        ReadConfig.readAloudWakeLock = value
+        settingsRepository.putBoolean(PreferKey.readAloudWakeLock, value)
     }
 
     suspend fun setShowReadAloudCapsule(value: Boolean) {
-        ReadConfig.showReadAloudCapsule = value
+        settingsRepository.putBoolean(PreferKey.showReadAloudCapsule, value)
     }
 
     suspend fun setCapsulePosition(x: Float, y: Float) {
@@ -80,40 +99,40 @@ class ReadAloudSettingsRepository(
     suspend fun resetCapsulePosition() = setCapsulePosition(0f, 0f)
 
     suspend fun setMediaButtonPerNext(value: Boolean) {
-        ReadConfig.mediaButtonPerNext = value
+        settingsRepository.putBoolean(KEY_MEDIA_BUTTON_PER_NEXT, value)
     }
 
     suspend fun setReadAloudByPage(value: Boolean) {
-        ReadConfig.readAloudByPage = value
+        settingsRepository.putBoolean(PreferKey.readAloudByPage, value)
     }
 
     suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) {
-        ReadConfig.systemMediaControlCompatibilityChange = value
+        settingsRepository.putBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
     }
 
     suspend fun setStreamReadAloudAudio(value: Boolean) {
-        ReadConfig.streamReadAloudAudio = value
+        settingsRepository.putBoolean(PreferKey.streamReadAloudAudio, value)
     }
 
     suspend fun setTtsTimer(value: Int) {
         val timer = PlaybackTimer.normalize(value)
-        ReadConfig.ttsTimer = timer
+        settingsRepository.putInt(PreferKey.ttsTimer, timer)
     }
 
     suspend fun setTtsFollowSys(value: Boolean) {
-        ReadConfig.ttsFollowSys = value
+        settingsRepository.putBoolean(PreferKey.ttsFollowSys, value)
     }
 
     suspend fun setTtsSpeechRate(value: Int) {
-        ReadConfig.ttsSpeechRate = value.coerceIn(0, 80)
+        settingsRepository.putInt(PreferKey.ttsSpeechRate, value.coerceIn(0, 80))
     }
 
     suspend fun setSpeechAnalysisMode(value: String) {
-        ReadConfig.speechAnalysisMode = value
+        settingsRepository.putString(PreferKey.speechAnalysisMode, value)
     }
 
     suspend fun setUseMultiSpeaker(value: Boolean) {
-        ReadConfig.useMultiSpeaker = value
+        settingsRepository.putBoolean(PreferKey.useMultiSpeaker, value)
     }
 
     suspend fun setDefaultInterface(value: String) {
@@ -125,6 +144,9 @@ class ReadAloudSettingsRepository(
 
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
+            ttsEngine = compatDsString(PreferKey.ttsEngine),
+            ttsParagraphInterval = compatDsValue(Keys.TtsParagraphInterval, 0),
+            audioCacheCleanTime = compatDsValue(Keys.AudioCacheCleanTime, 10),
             ignoreAudioFocus = compatDsValue(Keys.IgnoreAudioFocus, false),
             mediaButtonOnExit = compatDsValue(Keys.MediaButtonOnExit, true),
             readAloudByMediaButton = compatDsValue(Keys.ReadAloudByMediaButton, false),
@@ -144,10 +166,18 @@ class ReadAloudSettingsRepository(
             speechAnalysisMode = compatDsValue(Keys.SpeechAnalysisMode, "rule"),
             useMultiSpeaker = compatDsValue(Keys.UseMultiSpeaker, true),
             defaultInterface = compatDsValue(Keys.DefaultInterface, DEFAULT_INTERFACE_CLASSIC),
+            contentSelectSpeakMode = compatDsValue(Keys.ContentSelectSpeakMode, 0),
+            audioPreDownloadNum = compatDsValue(Keys.AudioPreDownloadNum, 10),
         )
     }
 
     private object Keys {
+        val TtsParagraphInterval = androidx.datastore.preferences.core.intPreferencesKey(
+            PreferKey.ttsParagraphInterval
+        )
+        val AudioCacheCleanTime = androidx.datastore.preferences.core.intPreferencesKey(
+            PreferKey.audioCacheCleanTime
+        )
         val IgnoreAudioFocus = booleanPreferencesKey(PreferKey.ignoreAudioFocus)
         val MediaButtonOnExit = booleanPreferencesKey(PreferKey.mediaButtonOnExit)
         val ReadAloudByMediaButton = booleanPreferencesKey(PreferKey.readAloudByMediaButton)
@@ -170,6 +200,12 @@ class ReadAloudSettingsRepository(
         val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)
         val UseMultiSpeaker = booleanPreferencesKey(PreferKey.useMultiSpeaker)
         val DefaultInterface = stringPreferencesKey(PreferKey.defaultReadAloudInterface)
+        val ContentSelectSpeakMode = androidx.datastore.preferences.core.intPreferencesKey(
+            PreferKey.contentSelectSpeakMod
+        )
+        val AudioPreDownloadNum = androidx.datastore.preferences.core.intPreferencesKey(
+            PreferKey.audioPreDownloadNum
+        )
     }
 
     companion object {

+ 276 - 24
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -1,62 +1,143 @@
 package io.legado.app.data.repository
 
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.ReadStyleGateway
+import io.legado.app.domain.gateway.ReadStyleBooleanKey
+import io.legado.app.domain.gateway.ReadStyleColorKey
+import io.legado.app.domain.gateway.ReadStyleFloatKey
+import io.legado.app.domain.gateway.ReadStyleIntKey
+import io.legado.app.domain.gateway.ReadStyleMutation
+import io.legado.app.domain.gateway.ReadStyleStringKey
+import io.legado.app.domain.model.settings.ReadStyleItem
+import io.legado.app.domain.model.settings.ReadStyleState
+import io.legado.app.help.DefaultData
 import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
 import java.io.InputStream
 
-/**
- * Repository boundary for the legacy read style config file.
- *
- * The underlying model is still [ReadBookConfig.Config] because each read style
- * keeps a full layout/background/text configuration. This wrapper centralizes
- * file mutations so UI and ViewModel code do not call persistence helpers directly.
- */
 class ReadBookStyleConfigRepository(
     private val readStyleRepository: ReadStyleRepository,
     private val highlightRuleRepository: HighlightRuleRepository,
-) {
+) : ReadStyleGateway {
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val saveQueue = ReadStyleSaveQueue(
+        scope = scope,
+        persist = { snapshot ->
+            readStyleRepository.save(snapshot.configs, snapshot.shareConfig)
+        },
+        onFailure = { error ->
+            AppLog.put("保存排版配置文件出错", error)
+        },
+    )
+    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()
+        publishState()
+    }
+
+    override fun save() {
+        publishState()
+        saveQueue.submit(
+            ReadStyleSaveSnapshot(
+                configs = ReadBookConfig.configsSnapshot(),
+                shareConfig = ReadBookConfig.shareConfigSnapshot(),
+            )
+        )
+    }
+
+    override fun updateCurrentStyle(mutation: ReadStyleMutation) {
+        when (mutation) {
+            is ReadStyleMutation.IntValue -> updateInt(mutation.key, mutation.value)
+            is ReadStyleMutation.FloatValue -> updateFloat(mutation.key, mutation.value)
+            is ReadStyleMutation.BooleanValue -> updateBoolean(mutation.key, mutation.value)
+            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)
+        }
+        publishState()
+    }
 
-    fun save() {
-        ReadBookConfig.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()
+            ?: return false
+        ReadBookConfig.durConfig = copy
+        save()
+        return true
     }
 
-    fun addStyle(): Int {
-        ReadBookConfig.configList.add(ReadBookConfig.Config())
+    override fun addStyle(): Int {
+        val index = ReadBookConfig.addConfig(ReadBookConfig.Config())
         save()
-        return ReadBookConfig.configList.lastIndex
+        return index
     }
 
-    fun deleteCurrentStyle(): Boolean {
+    override fun deleteCurrentStyle(): Boolean {
         val deletedConfigName = ReadBookConfig.durConfig.name
-        val deleted = ReadBookConfig.deleteDur()
-        if (deleted) {
+        val removedIndex = ReadBookConfig.deleteDur()
+        if (removedIndex != null) {
+            val readIndex = AppConfigStore.getInt(PreferKey.readStyleSelect) ?: 0
+            val comicIndex = AppConfigStore.getInt(PreferKey.comicStyleSelect) ?: readIndex
+            AppConfigStore.putAll(
+                mapOf(
+                    PreferKey.readStyleSelect to if (removedIndex <= readIndex) {
+                        (readIndex - 1).coerceAtLeast(0)
+                    } else readIndex,
+                    PreferKey.comicStyleSelect to if (removedIndex <= comicIndex) {
+                        (comicIndex - 1).coerceAtLeast(0)
+                    } else comicIndex,
+                )
+            )
             highlightRuleRepository.removeConfigBinding(deletedConfigName)
             save()
         }
-        return deleted
+        return removedIndex != null
     }
 
-    fun importCurrentStyle(bytes: ByteArray) {
+    override fun importCurrentStyle(bytes: ByteArray) {
         ReadBookConfig.durConfig = readStyleRepository.import(bytes)
         save()
     }
 
-    fun exportCurrentStyle(): ByteArray {
+    override fun importOrReplaceStyle(bytes: ByteArray): String {
+        val name = ReadBookConfig.importOrReplaceConfig(readStyleRepository.import(bytes))
+        save()
+        return name
+    }
+
+    override fun exportCurrentStyle(): ByteArray {
         val config = ReadBookConfig.getExportConfig().copy(
             highlightRules = ArrayList(highlightRuleRepository.load(ReadBookConfig.durConfig.name))
         )
         return readStyleRepository.export(config)
     }
 
-    fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String {
-        return ReadBookConfig.saveBackgroundImage(inputStream, displayName)
-    }
+    override fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String =
+        readStyleRepository.saveBackgroundImage(inputStream, displayName)
 
-    fun setCurrentBackgroundImage(path: String) {
+    override fun setCurrentBackgroundImage(path: String) {
         ReadBookConfig.durConfig.setCurBg(2, path)
         save()
     }
 
-    fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) {
+    override fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) {
         if (isNight) {
             ReadBookConfig.durConfig.bgTypeNight = 2
             ReadBookConfig.durConfig.bgStrNight = path
@@ -67,4 +148,175 @@ class ReadBookStyleConfigRepository(
         save()
     }
 
+    override fun exportConfigsJson(): String = GSON.toJson(ReadBookConfig.configsSnapshot())
+
+    override fun exportShareConfigJson(): String = GSON.toJson(ReadBookConfig.shareConfigSnapshot())
+
+    private fun publishState() {
+        _state.value = buildState()
+    }
+
+    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
+        }
+    }
+
+    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
+        }
+    }
+
+    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)
+        }
+    }
+
+    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
+        }
+    }
+
+    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)
+        }
+    }
+
+    private fun buildState(): ReadStyleState = ReadStyleState(
+        items = ReadBookConfig.configsSnapshot().map { config ->
+            ReadStyleItem(
+                name = config.name,
+                bgType = config.bgType,
+                bgValue = config.bgStr,
+                bgTypeNight = config.bgTypeNight,
+                bgValueNight = config.bgStrNight,
+                bgTypeEInk = config.bgTypeEInk,
+                bgValueEInk = config.bgStrEInk,
+                textColor = config.getTextColor().toColorIntOrDefault(),
+                textColorNight = config.getTextColorNight().toColorIntOrDefault(),
+                textColorEInk = config.getTextColorEInk().toColorIntOrDefault(),
+            )
+        },
+        selectedIndex = ReadBookConfig.styleSelect,
+        shareLayout = ReadBookConfig.shareLayout,
+    )
+
+    private fun String.toColorIntOrDefault(): Int =
+        runCatching { android.graphics.Color.parseColor(this) }.getOrDefault(0)
+}
+
+internal data class ReadStyleSaveSnapshot(
+    val configs: List<ReadBookConfig.Config>,
+    val shareConfig: ReadBookConfig.Config,
+)
+
+/**
+ * 排版配置是完整快照,队列中只需保留最新一份待保存值。
+ * 单次文件异常只丢弃该次快照,不得终止后续保存消费。
+ */
+internal class ReadStyleSaveQueue(
+    scope: CoroutineScope,
+    private val persist: (ReadStyleSaveSnapshot) -> Unit,
+    private val onFailure: (Throwable) -> Unit,
+) {
+    private val snapshots = Channel<ReadStyleSaveSnapshot>(Channel.CONFLATED)
+
+    init {
+        scope.launch {
+            for (snapshot in snapshots) {
+                try {
+                    persist(snapshot)
+                } catch (error: CancellationException) {
+                    throw error
+                } catch (error: Exception) {
+                    onFailure(error)
+                }
+            }
+        }
+    }
+
+    fun submit(snapshot: ReadStyleSaveSnapshot) {
+        snapshots.trySend(snapshot).getOrThrow()
+    }
 }

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

@@ -14,15 +14,20 @@ import io.legado.app.domain.model.settings.ReadSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsValue
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.map
 
 typealias ReadPreferences = ReadSettings
 
 class ReadSettingsRepository(
-    private val settingsRepository: SettingsRepository
+    private val settingsRepository: SettingsRepository,
+    private val preferencesFlow: StateFlow<Preferences> = AppConfigStore.preferencesFlow,
 ) : ReadSettingsGateway {
 
-    override val settings: Flow<ReadSettings> = AppConfigStore.preferencesFlow
+    override val currentSettings: ReadSettings
+        get() = preferencesFlow.value.toReadSettings()
+
+    override val settings: Flow<ReadSettings> = preferencesFlow
         .map { preferences ->
             preferences.toReadSettings()
         }
@@ -46,6 +51,8 @@ class ReadSettingsRepository(
             is ReadSettingsUpdate.UseZhLayout -> setUseZhLayout(update.value)
             is ReadSettingsUpdate.ShowBrightnessView -> setShowBrightnessView(update.value)
             is ReadSettingsUpdate.BrightnessVwPos -> setBrightnessVwPos(update.value)
+            is ReadSettingsUpdate.Brightness -> setReadBrightness(update.value)
+            is ReadSettingsUpdate.BrightnessAuto -> setBrightnessAuto(update.value)
             is ReadSettingsUpdate.UseUnderline -> setUseUnderline(update.value)
             is ReadSettingsUpdate.ReadSliderMode -> setReadSliderMode(update.value)
             is ReadSettingsUpdate.DoubleHorizontalPage -> setDoubleHorizontalPage(update.value)
@@ -65,9 +72,13 @@ class ReadSettingsRepository(
             is ReadSettingsUpdate.OptimizeRender -> setOptimizeRender(update.value)
             is ReadSettingsUpdate.DisableReturnKey -> setDisableReturnKey(update.value)
             is ReadSettingsUpdate.ShowReadTitleAddition -> setShowReadTitleAddition(update.value)
+            is ReadSettingsUpdate.TextSelectMenuConfig -> settingsRepository.putString(PreferKey.textSelectMenuConfig, update.value)
+            is ReadSettingsUpdate.ReadUrlInBrowser -> settingsRepository.putBoolean(PreferKey.readUrlOpenInBrowser, update.value)
             is ReadSettingsUpdate.ShowMenuIcon -> setShowMenuIcon(update.value)
             is ReadSettingsUpdate.PageKeys -> setPageKeys(update.previous, update.next)
             is ReadSettingsUpdate.FontFolder -> setFontFolder(update.value)
+            is ReadSettingsUpdate.SystemTypefaces -> setSystemTypefaces(update.value)
+            is ReadSettingsUpdate.PreDownloadNum -> setPreDownloadNum(update.value)
         }
     }
 
@@ -188,6 +199,12 @@ class ReadSettingsRepository(
     suspend fun setAutoReadSpeed(value: Int) =
         settingsRepository.putInt(PreferKey.autoReadSpeed, value)
 
+    suspend fun setSystemTypefaces(value: Int) =
+        settingsRepository.putInt(PreferKey.systemTypefaces, value)
+
+    suspend fun setPreDownloadNum(value: Int) =
+        settingsRepository.putInt(PreferKey.preDownloadNum, value)
+
     suspend fun setPageKeys(prevKeys: String, nextKeys: String) {
         settingsRepository.putStrings(
             mapOf(
@@ -346,7 +363,7 @@ class ReadSettingsRepository(
         }
     }
 
-    private fun Preferences.toReadSettings(): ReadSettings {
+    internal fun Preferences.toReadSettings(): ReadSettings {
         val readStyleSelect = compatDsValue(Keys.ReadStyleSelect, 0)
         return ReadSettings(
             screenOrientation = compatDsValue(Keys.ScreenOrientation, "0"),
@@ -386,12 +403,16 @@ class ReadSettingsRepository(
             disableReturnKey = compatDsValue(Keys.DisableReturnKey, false),
             expandTextMenu = compatDsValue(Keys.ExpandTextMenu, false),
             showSelectMenuIcon = compatDsValue(Keys.ShowSelectMenuIcon, true),
+            textSelectMenuConfig = compatDsValue(Keys.TextSelectMenuConfig, ""),
             showReadTitleAddition = compatDsValue(Keys.ShowReadTitleAddition, true),
             autoReadSpeed = compatDsValue(Keys.AutoReadSpeed, 10),
+            systemTypefaces = compatDsValue(Keys.SystemTypefaces, 0),
+            preDownloadNum = compatDsValue(Keys.PreDownloadNum, 10),
             prevKeys = compatDsValue(Keys.PrevKeys, ""),
             nextKeys = compatDsValue(Keys.NextKeys, ""),
             tocUiUseReplace = compatDsValue(Keys.TocUiUseReplace, false),
             tocCountWords = compatDsValue(Keys.TocCountWords, true),
+            readUrlInBrowser = compatDsValue(Keys.ReadUrlInBrowser, false),
             readStyleSelect = readStyleSelect,
             comicStyleSelect = compatDsValue(Keys.ComicStyleSelect, readStyleSelect),
             shareLayout = compatDsValue(Keys.ShareLayout, false),
@@ -442,7 +463,7 @@ class ReadSettingsRepository(
             readMenuCustomIcons = compatDsValue(Keys.ReadMenuCustomIcons, ""),
             titleBarCustomIcons = compatDsValue(Keys.TitleBarCustomIcons, ""),
             titleBarIconPosition = compatDsValue(Keys.TitleBarIconPosition, 0),
-            showTitleBarIcons = compatDsValue(Keys.ShowTitleBarIcons, false),
+            showTitleBarIcons = compatDsValue(Keys.ShowTitleBarIcons, true),
             chineseConverterType = compatDsValue(Keys.ChineseConverterType, 0),
             showMenuIcon = compatDsValue(Keys.ShowMenuIcon, true),
         )
@@ -486,12 +507,16 @@ class ReadSettingsRepository(
         val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey)
         val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
         val ShowSelectMenuIcon = booleanPreferencesKey(PreferKey.showSelectMenuIcon)
+        val TextSelectMenuConfig = stringPreferencesKey(PreferKey.textSelectMenuConfig)
         val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
         val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
+        val SystemTypefaces = intPreferencesKey(PreferKey.systemTypefaces)
+        val PreDownloadNum = intPreferencesKey(PreferKey.preDownloadNum)
         val PrevKeys = stringPreferencesKey(PreferKey.prevKeys)
         val NextKeys = stringPreferencesKey(PreferKey.nextKeys)
         val TocUiUseReplace = booleanPreferencesKey(PreferKey.tocUiUseReplace)
         val TocCountWords = booleanPreferencesKey(PreferKey.tocCountWords)
+        val ReadUrlInBrowser = booleanPreferencesKey(PreferKey.readUrlOpenInBrowser)
         val ReadStyleSelect = intPreferencesKey(PreferKey.readStyleSelect)
         val ComicStyleSelect = intPreferencesKey(PreferKey.comicStyleSelect)
         val ShareLayout = booleanPreferencesKey(PreferKey.shareLayout)

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

@@ -1,6 +1,7 @@
 package io.legado.app.data.repository
 
 import androidx.core.graphics.toColorInt
+import androidx.core.net.toUri
 import io.legado.app.constant.AppLog
 import io.legado.app.help.DefaultData
 import io.legado.app.help.config.ReadBookConfig
@@ -14,6 +15,7 @@ import io.legado.app.utils.externalFiles
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getFile
+import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.printOnDebug
 import splitties.init.appCtx
 import java.io.File
@@ -129,6 +131,14 @@ class ReadStyleRepository(
         addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
         exportConfig.textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles)
         exportConfig.titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles)
+        HighlightRuleAssetTransfer.prepareExport(
+            rules = exportConfig.highlightRules,
+            exportDir = exportDir,
+            copyAsset = ::copyRuleAsset,
+        ).let { result ->
+            exportConfig.highlightRules = ArrayList(result.rules)
+            exportFiles.addAll(result.files)
+        }
 
         val configFile = exportDir.getFile(ReadBookConfig.configFileName)
         configFile.writeText(GSON.toJson(exportConfig))
@@ -201,6 +211,16 @@ class ReadStyleRepository(
         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 targetConfigName = config.name.ifBlank { null }
             val highlightRules = config.highlightRules.map { rule ->
                 if (targetConfigName.isNullOrBlank()) {
@@ -253,6 +273,22 @@ class ReadStyleRepository(
         return target.name
     }
 
+    private fun copyRuleAsset(sourcePath: String, target: File): Boolean {
+        return runCatching {
+            target.parentFile?.mkdirs()
+            if (sourcePath.isContentScheme()) {
+                appCtx.contentResolver.openInputStream(sourcePath.toUri())?.use { input ->
+                    target.outputStream().use(input::copyTo)
+                } ?: return@runCatching false
+            } else {
+                val source = File(sourcePath)
+                if (!source.isFile) return@runCatching false
+                source.copyTo(target, overwrite = true)
+            }
+            true
+        }.getOrDefault(false)
+    }
+
     private fun importFont(configDir: File, fontName: String): String {
         if (fontName.isEmpty()) {
             return ""

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

@@ -2,6 +2,7 @@ package io.legado.app.data.repository
 
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.BookType
+import io.legado.app.constant.PreferKey
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Server
@@ -9,7 +10,7 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.getRemoteUrl
 import io.legado.app.help.book.isLocal
-import io.legado.app.ui.config.importBookConfig.ImportBookConfig
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.lib.webdav.Authorization
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.CustomUrl
@@ -33,7 +34,8 @@ class RemoteBookRepository(
             }
         }
 
-        val currentServerId = ImportBookConfig.remoteServerId
+        val currentServerId = AppConfigStore.getLong(PreferKey.remoteServerId)
+            ?: AppConst.DEFAULT_WEBDAV_ID
         if (currentServerId != AppConst.DEFAULT_WEBDAV_ID) {
             appDb.serverDao.get(currentServerId)?.getWebDavConfig()?.let {
                 return RemoteBookWebDav(it.url, Authorization(it), currentServerId)

+ 15 - 5
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -5,10 +5,11 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.data.dao.SearchContentHistoryDao
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.utils.ChineseUtils
 import kotlinx.coroutines.Dispatchers
@@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.flowOn
 class SearchContentRepository(
     private val titleModeProvider: () -> Int = { 0 },
     private val historyDao: SearchContentHistoryDao? = null,
+    private val readSettingsGateway: ReadSettingsGateway? = null,
+    private val otherSettingsGateway: OtherSettingsGateway? = null,
 ) {
 
     private val effectiveHistoryDao: SearchContentHistoryDao
@@ -97,6 +100,8 @@ class SearchContentRepository(
         val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl)
         val totalChapters = chapters.size
         val contentProcessor = ContentProcessor.get(book.name, book.origin)
+        val chineseConverterType = readSettingsGateway?.currentSettings?.chineseConverterType ?: 0
+        val defaultReplaceEnabled = otherSettingsGateway?.currentSettings?.replaceEnableDefault ?: true
         val cacheChapterNames = BookHelp.getChapterFiles(book).toHashSet()
 
         val allResults = mutableListOf<SearchResult>()
@@ -112,7 +117,9 @@ class SearchContentRepository(
                     bookChapter,
                     contentProcessor,
                     replaceEnabled,
-                    regexReplace
+                    regexReplace,
+                    chineseConverterType,
+                    defaultReplaceEnabled,
                 ).map {
                     if (totalChapters > 0) {
                         it.copy(progressPercent = (bookChapter.index + 1).toFloat() / totalChapters * 100f)
@@ -166,12 +173,14 @@ class SearchContentRepository(
         chapter: BookChapter,
         contentProcessor: ContentProcessor,
         replaceEnabled: Boolean,
-        regexReplace: Boolean
+        regexReplace: Boolean,
+        chineseConverterType: Int,
+        defaultReplaceEnabled: Boolean,
     ): List<SearchResult> {
         val searchResultsWithinChapter: MutableList<SearchResult> = mutableListOf()
         val chapterContent = BookHelp.getContent(book, chapter) ?: return searchResultsWithinChapter
 
-        chapter.title = when (AppConfig.chineseConverterType) {
+        chapter.title = when (chineseConverterType) {
             1 -> ChineseUtils.t2s(chapter.title)
             2 -> ChineseUtils.s2t(chapter.title)
             else -> chapter.title
@@ -190,7 +199,8 @@ class SearchContentRepository(
         val mContent = if (includeTitle) {
             val title = chapter.getDisplayTitle(
                 contentProcessor.getTitleReplaceRules(),
-                useReplace = replaceEnabled && book.getUseReplaceRule()
+                useReplace = replaceEnabled && book.getUseReplaceRule(defaultReplaceEnabled),
+                chineseConverterType = chineseConverterType,
             )
             listOf(title).plus(bodyContent.textList).joinToString("\n")
         } else {

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

@@ -0,0 +1,9 @@
+package io.legado.app.data.repository
+
+internal inline fun <T> T.diffPrefMap(
+    transform: (T) -> T,
+    toPrefMap: (T) -> Map<String, Any?>,
+): Map<String, Any?> {
+    val previous = toPrefMap(this)
+    return toPrefMap(transform(this)).filter { (key, value) -> previous[key] != value }
+}

+ 18 - 0
app/src/main/java/io/legado/app/data/repository/TagGroupRuleApplier.kt

@@ -0,0 +1,18 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.AppDatabase
+import io.legado.app.help.book.applyTagGroupRules
+
+class TagGroupRuleApplier(
+    private val database: AppDatabase,
+) {
+
+    suspend fun applyInCurrentTransaction() {
+        applyTagGroupRules(
+            books = database.bookDao.getAll(),
+            rules = database.tagGroupRuleDao.getAll(),
+            groupDao = database.bookGroupDao,
+            bookDao = database.bookDao,
+        )
+    }
+}

+ 218 - 0
app/src/main/java/io/legado/app/data/repository/ThemePackageSettingsRepository.kt

@@ -0,0 +1,218 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.ThemePackageSettingsGateway
+import io.legado.app.domain.model.settings.ThemeExportData
+import io.legado.app.help.config.AppConfigStore
+
+class ThemePackageSettingsRepository : ThemePackageSettingsGateway {
+
+    override fun exportCurrent(): ThemeExportData = ThemeExportData(
+        appTheme = string(PreferKey.appTheme, "0"),
+        themeMode = string(PreferKey.themeMode, "0"),
+        isPureBlack = boolean(PreferKey.pureBlack, false),
+        composeEngine = string(PreferKey.composeEngine, "material"),
+        paletteStyle = string(PreferKey.paletteStyle, "tonalSpot"),
+        materialVersion = string(PreferKey.materialVersion, "material3"),
+        customMode = AppConfigStore.getString(PreferKey.customMode) ?: "tonalSpot",
+        customContrast = string(PreferKey.customContrast, "Default"),
+        launcherIcon = string(PreferKey.launcherIcon, "ic_launcher"),
+        isPredictiveBackEnabled = boolean(PreferKey.isPredictiveBackEnabled, true),
+        fontScale = int(PreferKey.fontScale, 10),
+        enableDeepPersonalization = boolean(PreferKey.enableDeepPersonalization, false),
+        cPrimary = int(PreferKey.cPrimary, 0),
+        cNPrimary = int(PreferKey.cNPrimary, 0),
+        themeColor = int(PreferKey.themeColor, 0),
+        secondaryThemeColor = int(PreferKey.secondaryThemeColor, 0),
+        primaryTextColor = int(PreferKey.primaryTextColor, 0),
+        secondaryTextColor = int(PreferKey.secondaryTextColor, 0),
+        themeBackgroundColor = int(PreferKey.themeBackgroundColor, 0),
+        labelContainerColor = int(PreferKey.labelContainerColor, 0),
+        themeColorNight = int(PreferKey.themeColorNight, 0),
+        secondaryThemeColorNight = int(PreferKey.secondaryThemeColorNight, 0),
+        primaryTextColorNight = int(PreferKey.primaryTextColorNight, 0),
+        secondaryTextColorNight = int(PreferKey.secondaryTextColorNight, 0),
+        themeBackgroundColorNight = int(PreferKey.themeBackgroundColorNight, 0),
+        labelContainerColorNight = int(PreferKey.labelContainerColorNight, 0),
+        bookInfoInputColor = int(PreferKey.bookInfoInputColor, 0),
+        bookInfoFollowCoverColor = boolean(PreferKey.bookInfoFollowCoverColor, true),
+        bookInfoBackgroundBlur = string(PreferKey.bookInfoBackgroundBlur, "on"),
+        bookInfoNetworkCoverBackground = AppConfigStore.getString(
+            PreferKey.bookInfoNetworkCoverBackground
+        ),
+        bookInfoDefaultCoverBackground = AppConfigStore.getString(
+            PreferKey.bookInfoDefaultCoverBackground
+        ),
+        containerOpacity = int(PreferKey.containerOpacity, 100),
+        enableItemDivider = boolean(PreferKey.enableItemDivider, false),
+        itemDividerWidth = float(PreferKey.itemDividerWidth, 1f),
+        itemDividerLength = float(PreferKey.itemDividerLength, 80f),
+        itemDividerColor = int(PreferKey.itemDividerColor, 0),
+        enableBlur = boolean(PreferKey.enableBlur, false),
+        enableProgressiveBlur = boolean(PreferKey.enableProgressiveBlur, false),
+        topBarBlurRadius = int(PreferKey.topBarBlurRadius, 24),
+        bottomBarBlurRadius = int(PreferKey.bottomBarBlurRadius, 8),
+        topBarBlurAlpha = int(PreferKey.topBarBlurAlpha, 73),
+        bottomBarBlurAlpha = int(PreferKey.bottomBarBlurAlpha, 40),
+        bottomBarLensRadius = float(PreferKey.bottomBarLensRadius, 24f),
+        topBarOpacity = int(PreferKey.topBarOpacity, 100),
+        bottomBarOpacity = int(PreferKey.bottomBarOpacity, 100),
+        enableCustomTagColors = boolean(PreferKey.enableCustomTagColors, false),
+        customTagColorsJson = AppConfigStore.getString(PreferKey.customTagColors),
+        bookshelfCardColor = int(PreferKey.bookshelfCardColor, 0),
+        bookshelfCardColorDark = int(PreferKey.bookshelfCardColorDark, 0),
+        showHome = boolean(PreferKey.showHome, true),
+        showDiscovery = boolean(PreferKey.showDiscovery, true),
+        showRss = boolean(PreferKey.showRss, true),
+        showStatusBar = boolean(PreferKey.showStatusBar, true),
+        swipeAnimation = boolean(PreferKey.swipeAnimation, true),
+        showBottomView = boolean(PreferKey.showBottomView, true),
+        useFloatingBottomBar = boolean(PreferKey.useFloatingBottomBar, false),
+        useFloatingBottomBarLiquidGlass = boolean(
+            PreferKey.useFloatingBottomBarLiquidGlass,
+            false,
+        ),
+        tabletInterface = string(PreferKey.tabletInterface, "auto"),
+        labelVisibilityMode = string(PreferKey.labelVisibilityMode, "auto"),
+        defaultHomePage = string(PreferKey.defaultHomePage, "bookshelf"),
+        mainNavigationOrder = string(
+            PreferKey.mainNavigationOrder,
+            "home,bookshelf,explore,rss,my",
+        ),
+        navIconHome = string(PreferKey.navIconHome, ""),
+        navIconBookshelf = string(PreferKey.navIconBookshelf, ""),
+        navIconExplore = string(PreferKey.navIconExplore, ""),
+        navIconRss = string(PreferKey.navIconRss, ""),
+        navIconMy = string(PreferKey.navIconMy, ""),
+        useMiuixMonet = boolean(PreferKey.useMiuixMonet, false),
+        useFlexibleTopAppBar = boolean(PreferKey.useFlexibleTopAppBar, true),
+        bgImageLight = AppConfigStore.getString(PreferKey.bgImage),
+        bgImageDark = AppConfigStore.getString(PreferKey.bgImageN),
+        bgImageBlurring = int(PreferKey.bgImageBlurring, 0),
+        bgImageNBlurring = int(PreferKey.bgImageNBlurring, 0),
+        appFontPath = AppConfigStore.getString(PreferKey.appFontPath),
+        coverLoadOnlyWifi = boolean(PreferKey.loadCoverOnlyWifi, false),
+        coverUseDefault = boolean(PreferKey.useDefaultCover, false),
+        coverShowShadow = boolean(PreferKey.coverShowShadow, false),
+        coverShowStroke = boolean(PreferKey.coverShowStroke, true),
+        coverDefaultColor = boolean(PreferKey.coverDefaultColor, true),
+        coverDefaultImage = string(PreferKey.defaultCover, ""),
+        coverTextColor = int(PreferKey.coverTextColor, -16777216),
+        coverShadowColor = int(PreferKey.coverShadowColor, -16777216),
+        coverShowName = boolean(PreferKey.coverShowName, true),
+        coverShowAuthor = boolean(PreferKey.coverShowAuthor, true),
+        coverDefaultImageDark = string(PreferKey.defaultCoverDark, ""),
+        coverTextColorN = int(PreferKey.coverTextColorN, -1),
+        coverShadowColorN = int(PreferKey.coverShadowColorN, -1),
+        coverShowNameN = boolean(PreferKey.coverShowNameN, true),
+        coverShowAuthorN = boolean(PreferKey.coverShowAuthorN, true),
+        coverInfoOrientation = string(PreferKey.coverInfoOrientation, "0"),
+    )
+
+    override suspend fun applyAndAwait(data: ThemeExportData) {
+        AppConfigStore.putAllAndAwait(data.toPreferenceValues())
+    }
+
+    private fun string(key: String, default: String) = AppConfigStore.getString(key) ?: default
+    private fun int(key: String, default: Int) = AppConfigStore.getInt(key) ?: default
+    private fun boolean(key: String, default: Boolean) = AppConfigStore.getBoolean(key) ?: default
+    private fun float(key: String, default: Float) = AppConfigStore.getFloat(key) ?: default
+}
+
+internal fun ThemeExportData.toPreferenceValues(): Map<String, Any?> {
+    val data = this
+    val bookInfoBackground = bookInfoBackgroundBlur
+    return mapOf(
+                PreferKey.appTheme to data.appTheme,
+                PreferKey.themeMode to data.themeMode,
+                PreferKey.pureBlack to data.isPureBlack,
+                PreferKey.composeEngine to data.composeEngine,
+                PreferKey.paletteStyle to data.paletteStyle,
+                PreferKey.materialVersion to data.materialVersion,
+                PreferKey.customMode to data.customMode,
+                PreferKey.customContrast to data.customContrast,
+                PreferKey.launcherIcon to data.launcherIcon,
+                PreferKey.isPredictiveBackEnabled to data.isPredictiveBackEnabled,
+                PreferKey.fontScale to data.fontScale,
+                PreferKey.enableDeepPersonalization to data.enableDeepPersonalization,
+                PreferKey.cPrimary to data.cPrimary,
+                PreferKey.cNPrimary to data.cNPrimary,
+                PreferKey.themeColor to data.themeColor,
+                PreferKey.secondaryThemeColor to data.secondaryThemeColor,
+                PreferKey.primaryTextColor to data.primaryTextColor,
+                PreferKey.secondaryTextColor to data.secondaryTextColor,
+                PreferKey.themeBackgroundColor to data.themeBackgroundColor,
+                PreferKey.labelContainerColor to data.labelContainerColor,
+                PreferKey.themeColorNight to data.themeColorNight,
+                PreferKey.secondaryThemeColorNight to data.secondaryThemeColorNight,
+                PreferKey.primaryTextColorNight to data.primaryTextColorNight,
+                PreferKey.secondaryTextColorNight to data.secondaryTextColorNight,
+                PreferKey.themeBackgroundColorNight to data.themeBackgroundColorNight,
+                PreferKey.labelContainerColorNight to data.labelContainerColorNight,
+                PreferKey.bookInfoInputColor to data.bookInfoInputColor,
+                PreferKey.bookInfoFollowCoverColor to data.bookInfoFollowCoverColor,
+                PreferKey.bookInfoBackgroundBlur to bookInfoBackground,
+                PreferKey.bookInfoNetworkCoverBackground to
+                    (data.bookInfoNetworkCoverBackground ?: bookInfoBackground),
+                PreferKey.bookInfoDefaultCoverBackground to
+                    (data.bookInfoDefaultCoverBackground ?: bookInfoBackground),
+                PreferKey.containerOpacity to data.containerOpacity,
+                PreferKey.enableItemDivider to data.enableItemDivider,
+                PreferKey.itemDividerWidth to data.itemDividerWidth,
+                PreferKey.itemDividerLength to data.itemDividerLength,
+                PreferKey.itemDividerColor to data.itemDividerColor,
+                PreferKey.enableBlur to data.enableBlur,
+                PreferKey.enableProgressiveBlur to data.enableProgressiveBlur,
+                PreferKey.topBarBlurRadius to data.topBarBlurRadius,
+                PreferKey.bottomBarBlurRadius to data.bottomBarBlurRadius,
+                PreferKey.topBarBlurAlpha to data.topBarBlurAlpha,
+                PreferKey.bottomBarBlurAlpha to data.bottomBarBlurAlpha,
+                PreferKey.bottomBarLensRadius to data.bottomBarLensRadius,
+                PreferKey.topBarOpacity to data.topBarOpacity,
+                PreferKey.bottomBarOpacity to data.bottomBarOpacity,
+                PreferKey.enableCustomTagColors to data.enableCustomTagColors,
+                PreferKey.customTagColors to data.customTagColorsJson,
+                PreferKey.bookshelfCardColor to data.bookshelfCardColor,
+                PreferKey.bookshelfCardColorDark to data.bookshelfCardColorDark,
+                PreferKey.showHome to data.showHome,
+                PreferKey.showDiscovery to data.showDiscovery,
+                PreferKey.showRss to data.showRss,
+                PreferKey.showStatusBar to data.showStatusBar,
+                PreferKey.swipeAnimation to data.swipeAnimation,
+                PreferKey.showBottomView to data.showBottomView,
+                PreferKey.useFloatingBottomBar to data.useFloatingBottomBar,
+                PreferKey.useFloatingBottomBarLiquidGlass to data.useFloatingBottomBarLiquidGlass,
+                PreferKey.tabletInterface to data.tabletInterface,
+                PreferKey.labelVisibilityMode to data.labelVisibilityMode,
+                PreferKey.defaultHomePage to data.defaultHomePage,
+                PreferKey.mainNavigationOrder to data.mainNavigationOrder,
+                PreferKey.navIconHome to data.navIconHome,
+                PreferKey.navIconBookshelf to data.navIconBookshelf,
+                PreferKey.navIconExplore to data.navIconExplore,
+                PreferKey.navIconRss to data.navIconRss,
+                PreferKey.navIconMy to data.navIconMy,
+                PreferKey.useMiuixMonet to data.useMiuixMonet,
+                PreferKey.useFlexibleTopAppBar to data.useFlexibleTopAppBar,
+                PreferKey.bgImage to data.bgImageLight,
+                PreferKey.bgImageN to data.bgImageDark,
+                PreferKey.bgImageBlurring to data.bgImageBlurring,
+                PreferKey.bgImageNBlurring to data.bgImageNBlurring,
+                PreferKey.appFontPath to data.appFontPath,
+                PreferKey.loadCoverOnlyWifi to data.coverLoadOnlyWifi,
+                PreferKey.useDefaultCover to data.coverUseDefault,
+                PreferKey.coverShowShadow to data.coverShowShadow,
+                PreferKey.coverShowStroke to data.coverShowStroke,
+                PreferKey.coverDefaultColor to data.coverDefaultColor,
+                PreferKey.defaultCover to data.coverDefaultImage,
+                PreferKey.coverTextColor to data.coverTextColor,
+                PreferKey.coverShadowColor to data.coverShadowColor,
+                PreferKey.coverShowName to data.coverShowName,
+                PreferKey.coverShowAuthor to data.coverShowAuthor,
+                PreferKey.defaultCoverDark to data.coverDefaultImageDark,
+                PreferKey.coverTextColorN to data.coverTextColorN,
+                PreferKey.coverShadowColorN to data.coverShadowColorN,
+                PreferKey.coverShowNameN to data.coverShowNameN,
+                PreferKey.coverShowAuthorN to data.coverShowAuthorN,
+                PreferKey.coverInfoOrientation to data.coverInfoOrientation,
+    )
+}

+ 65 - 3
app/src/main/java/io/legado/app/di/appModule.kt

@@ -20,14 +20,18 @@ 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.BookCacheCleanupRepository
+import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookContentProcessRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookGroupMutationRepository
 import io.legado.app.data.repository.BookKnowledgeRepository
 import io.legado.app.data.repository.ChapterSpeechRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.ChangeSourceSettingsRepository
+import io.legado.app.data.repository.CheckSourceSettingsRepository
 import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.BookshelfSettingsRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
@@ -37,14 +41,20 @@ import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DictionaryRepositoryImpl
 import io.legado.app.data.repository.DirectLinkUploadRepository
+import io.legado.app.data.repository.DirectLinkSettingsRepository
 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.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
+import io.legado.app.data.repository.HomepageSettingsRepository
+import io.legado.app.data.repository.ImportBookSettingsRepository
 import io.legado.app.data.repository.LabSettingsRepository
+import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.LocalBookRepository
+import io.legado.app.data.repository.LocalPasswordRepository
+import io.legado.app.data.repository.OtherConfigSystemRepository
 import io.legado.app.data.repository.OtherSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
@@ -64,6 +74,8 @@ import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.ThemeSettingsRepository
+import io.legado.app.data.repository.TagGroupRuleApplier
+import io.legado.app.data.repository.ThemePackageSettingsRepository
 import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.TranslationSettingsRepository
@@ -86,27 +98,39 @@ import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
+import io.legado.app.domain.gateway.BookExportSettingsGateway
 import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.gateway.BookGroupMutationGateway
 import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
+import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.CoverAlbumGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.gateway.ThemePackageSettingsGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
+import io.legado.app.domain.gateway.DirectLinkSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.gateway.HomepageSettingsGateway
+import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.LabSettingsGateway
+import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.LocalBookGateway
+import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.ReadAloudVoiceGateway
 import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.TranslationSettingsGateway
@@ -236,6 +260,8 @@ import io.legado.app.ui.rss.read.ReadRssViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceViewModel
 import io.legado.app.ui.rss.subscription.RuleSubViewModel
 import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
+import io.legado.app.utils.isNightMode
+import io.legado.app.utils.sysConfiguration
 import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
@@ -257,6 +283,8 @@ val appModule = module {
     single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
     singleOf(::BookRepository)
     singleOf(::BookGroupRepository)
+    singleOf(::TagGroupRuleApplier)
+    single<BookGroupMutationGateway> { BookGroupMutationRepository(get(), get()) }
     singleOf(::BookSourceRepository)
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
@@ -265,6 +293,8 @@ val appModule = module {
         SearchContentRepository(
             titleModeProvider = { io.legado.app.help.config.ReadBookConfig.titleMode },
             historyDao = get(),
+            readSettingsGateway = get(),
+            otherSettingsGateway = get(),
         )
     }
     singleOf(::RemoteBookRepository)
@@ -272,17 +302,28 @@ val appModule = module {
     single<AppLocaleGateway> { AppLocaleRepository() }
     single<AppShellSettingsGateway> { AppShellSettingsRepository() }
     single<ThemeSettingsGateway> { ThemeSettingsRepository() }
+    single<ThemePackageSettingsGateway> { ThemePackageSettingsRepository() }
     single<AppUiConfigurationGateway> {
-        AppUiConfigurationRepository(get(), get(), get())
+        AppUiConfigurationRepository(
+            appLocaleGateway = get(),
+            initialSystemDarkTheme = sysConfiguration.isNightMode,
+        )
     }
     single<OtherSettingsGateway> { OtherSettingsRepository() }
+    single<CheckSourceSettingsGateway> { CheckSourceSettingsRepository(get()) }
+    single<DirectLinkSettingsGateway> { DirectLinkSettingsRepository() }
+    single<LocalPasswordGateway> { LocalPasswordRepository() }
+    single<OtherConfigSystemGateway> { OtherConfigSystemRepository(get()) }
     single<DownloadCacheSettingsGateway> { DownloadCacheSettingsRepository() }
     single<CoverSettingsGateway> { CoverSettingsRepository() }
     single<BackupSettingsGateway> { BackupSettingsRepository() }
     single<LabSettingsGateway> { LabSettingsRepository() }
+    single<MangaSettingsGateway> { MangaSettingsRepository() }
+    single<ChangeSourceSettingsGateway> { ChangeSourceSettingsRepository() }
+    single<ImportBookSettingsGateway> { ImportBookSettingsRepository() }
     single<TranslationSettingsGateway> { TranslationSettingsRepository() }
     single<BookshelfSettingsGateway> { BookshelfSettingsRepository() }
-    singleOf(::ReadSettingsRepository)
+    single { ReadSettingsRepository(settingsRepository = get()) }
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
@@ -292,6 +333,7 @@ val appModule = module {
     singleOf(::HighlightRuleRepository)
     singleOf(::ReadStyleRepository)
     singleOf(::ReadBookStyleConfigRepository)
+    single<ReadStyleGateway> { get<ReadBookStyleConfigRepository>() }
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)
@@ -339,6 +381,8 @@ val appModule = module {
     single<BackupRestoreGateway> { BackupRestoreRepository() }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
     single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
+    single<BookExportSettingsGateway> { BookExportSettingsRepository() }
+    single<HomepageSettingsGateway> { HomepageSettingsRepository() }
     single<CoverAlbumGateway> { CoverAlbumRepository(get(), get()) }
     single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
     single<LocalBookGateway> { LocalBookRepository(get()) }
@@ -417,7 +461,18 @@ val appModule = module {
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
-    viewModel { OtherConfigViewModel(get(), get(), get()) }
+    viewModel {
+        OtherConfigViewModel(
+            appLocaleGateway = get(),
+            readAloudSettingsGateway = get(),
+            otherSettingsGateway = get(),
+            downloadCacheSettingsGateway = get(),
+            checkSourceSettingsGateway = get(),
+            directLinkSettingsGateway = get(),
+            localPasswordGateway = get(),
+            systemGateway = get(),
+        )
+    }
     viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)
@@ -533,6 +588,12 @@ val appModule = module {
             syncReadAloudVoicesUseCase = get(),
             readAloudSessionStore = get(),
             replaceRuleRepository = get(),
+            changeSourceSettingsGateway = get(),
+            appShellSettingsGateway = get(),
+            appUiConfigurationGateway = get(),
+            otherSettingsGateway = get(),
+            downloadCacheSettingsGateway = get(),
+            backupSettingsGateway = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)
@@ -551,6 +612,7 @@ val appModule = module {
             bookGroupRepository = get(),
             searchRepository = get(),
             bookshelfManageScreenConfig = get(),
+            bookExportSettingsGateway = get(),
             batchCacheDownloadUseCase = get(),
             cacheBookChaptersUseCase = get(),
             changeBookSourceUseCase = get(),

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

@@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.StateFlow
 interface AppUiConfigurationGateway {
     val currentConfiguration: AppUiConfiguration
     val configuration: StateFlow<AppUiConfiguration>
+
+    fun synchronizeSystemDarkTheme(isDarkTheme: Boolean)
 }

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

@@ -4,6 +4,7 @@ import io.legado.app.domain.model.settings.BackupSettings
 import kotlinx.coroutines.flow.Flow
 
 interface BackupSettingsGateway {
+    val currentSettings: BackupSettings
     val settings: Flow<BackupSettings>
     suspend fun update(update: BackupSettingsUpdate)
     suspend fun updateAll(updates: List<BackupSettingsUpdate>)

+ 10 - 0
app/src/main/java/io/legado/app/domain/gateway/BookExportSettingsGateway.kt

@@ -0,0 +1,10 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.BookExportSettings
+import kotlinx.coroutines.flow.Flow
+
+interface BookExportSettingsGateway {
+    val currentSettings: BookExportSettings
+    val settings: Flow<BookExportSettings>
+    suspend fun update(transform: (BookExportSettings) -> BookExportSettings)
+}

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

@@ -0,0 +1,20 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.BookGroupUpdate
+import io.legado.app.domain.model.NewBookGroup
+import io.legado.app.domain.model.TagGroupRuleUpdate
+
+interface BookGroupMutationGateway {
+
+    suspend fun addGroup(group: NewBookGroup)
+
+    suspend fun saveGroup(
+        bookGroup: BookGroupUpdate,
+        ruleToSave: TagGroupRuleUpdate?,
+        ruleIdToDelete: Long?,
+    )
+
+    suspend fun saveTagGroupRule(rule: TagGroupRuleUpdate)
+
+    suspend fun deleteTagGroupRule(ruleId: Long)
+}

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

@@ -35,6 +35,7 @@ enum class BookshelfBooleanSetting {
     CoverShadow,
     SearchActionDirectToSearch,
     AutoRefresh,
+    HideEmptyGroups,
 }
 
 enum class BookshelfIntSetting {

+ 12 - 0
app/src/main/java/io/legado/app/domain/gateway/ChangeSourceSettingsGateway.kt

@@ -0,0 +1,12 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ChangeSourceSettings
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import kotlinx.coroutines.flow.Flow
+
+interface ChangeSourceSettingsGateway {
+    val currentSettings: ChangeSourceSettings
+    val settings: Flow<ChangeSourceSettings>
+    suspend fun update(transform: (ChangeSourceSettings) -> ChangeSourceSettings)
+    suspend fun setMigrationOptions(options: ChangeSourceMigrationOptions)
+}

+ 2 - 15
app/src/main/java/io/legado/app/domain/gateway/CoverSettingsGateway.kt

@@ -4,20 +4,7 @@ import io.legado.app.domain.model.settings.CoverSettings
 import kotlinx.coroutines.flow.Flow
 
 interface CoverSettingsGateway {
+    val currentSettings: CoverSettings
     val settings: Flow<CoverSettings>
-    suspend fun update(update: CoverSettingsUpdate)
-}
-
-sealed interface CoverSettingsUpdate {
-    data class LoadOnlyOnWifi(val value: Boolean) : CoverSettingsUpdate
-    data class UseDefaultCover(val value: Boolean) : CoverSettingsUpdate
-    data class ShowShadow(val value: Boolean) : CoverSettingsUpdate
-    data class ShowStroke(val value: Boolean) : CoverSettingsUpdate
-    data class UseDefaultColor(val value: Boolean) : CoverSettingsUpdate
-    data class TextColor(val value: Int, val dark: Boolean) : CoverSettingsUpdate
-    data class ShadowColor(val value: Int, val dark: Boolean) : CoverSettingsUpdate
-    data class ShowName(val value: Boolean, val dark: Boolean) : CoverSettingsUpdate
-    data class ShowAuthor(val value: Boolean, val dark: Boolean) : CoverSettingsUpdate
-    data class InfoOrientation(val value: String) : CoverSettingsUpdate
-    data class ExploreFilterState(val value: Int) : CoverSettingsUpdate
+    suspend fun update(transform: (CoverSettings) -> CoverSettings)
 }

+ 2 - 11
app/src/main/java/io/legado/app/domain/gateway/DownloadCacheSettingsGateway.kt

@@ -4,16 +4,7 @@ import io.legado.app.domain.model.settings.DownloadCacheSettings
 import kotlinx.coroutines.flow.Flow
 
 interface DownloadCacheSettingsGateway {
+    val currentSettings: DownloadCacheSettings
     val settings: Flow<DownloadCacheSettings>
-    suspend fun update(update: DownloadCacheSettingsUpdate)
-}
-
-sealed interface DownloadCacheSettingsUpdate {
-    data class BitmapCacheSize(val value: Int) : DownloadCacheSettingsUpdate
-    data class ImageRetainNum(val value: Int) : DownloadCacheSettingsUpdate
-    data class PreDownloadNum(val value: Int) : DownloadCacheSettingsUpdate
-    data class ThreadCount(val value: Int) : DownloadCacheSettingsUpdate
-    data class CacheBookThreadCount(val value: Int) : DownloadCacheSettingsUpdate
-    data class UserAgent(val value: String) : DownloadCacheSettingsUpdate
-    data class CronetEnabled(val value: Boolean) : DownloadCacheSettingsUpdate
+    suspend fun update(transform: (DownloadCacheSettings) -> DownloadCacheSettings)
 }

+ 10 - 0
app/src/main/java/io/legado/app/domain/gateway/HomepageSettingsGateway.kt

@@ -0,0 +1,10 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.HomepageSettings
+import kotlinx.coroutines.flow.Flow
+
+interface HomepageSettingsGateway {
+    val currentSettings: HomepageSettings
+    val settings: Flow<HomepageSettings>
+    suspend fun setHiddenSourceUrlsJson(value: String)
+}

+ 10 - 0
app/src/main/java/io/legado/app/domain/gateway/ImportBookSettingsGateway.kt

@@ -0,0 +1,10 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ImportBookSettings
+import kotlinx.coroutines.flow.Flow
+
+interface ImportBookSettingsGateway {
+    val currentSettings: ImportBookSettings
+    val settings: Flow<ImportBookSettings>
+    suspend fun update(transform: (ImportBookSettings) -> ImportBookSettings)
+}

+ 2 - 7
app/src/main/java/io/legado/app/domain/gateway/LabSettingsGateway.kt

@@ -4,12 +4,7 @@ import io.legado.app.domain.model.settings.LabSettings
 import kotlinx.coroutines.flow.Flow
 
 interface LabSettingsGateway {
+    val currentSettings: LabSettings
     val settings: Flow<LabSettings>
-    suspend fun update(update: LabSettingsUpdate)
-}
-
-sealed interface LabSettingsUpdate {
-    data class Enabled(val value: Boolean) : LabSettingsUpdate
-    data class EInkDisplay(val value: Boolean) : LabSettingsUpdate
-    data class EyeProtection(val value: Boolean) : LabSettingsUpdate
+    suspend fun update(transform: (LabSettings) -> LabSettings)
 }

+ 35 - 0
app/src/main/java/io/legado/app/domain/gateway/MangaSettingsGateway.kt

@@ -0,0 +1,35 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.MangaSettings
+import kotlinx.coroutines.flow.Flow
+
+interface MangaSettingsGateway {
+    val currentSettings: MangaSettings
+    val settings: Flow<MangaSettings>
+    suspend fun update(update: MangaSettingsUpdate)
+    suspend fun updateAll(updates: List<MangaSettingsUpdate>)
+}
+
+sealed interface MangaSettingsUpdate {
+    data class ShowMangaUi(val value: Boolean) : MangaSettingsUpdate
+    data class DisableScale(val value: Boolean) : MangaSettingsUpdate
+    data class DisableScrollAnimation(val value: Boolean) : MangaSettingsUpdate
+    data class DisableCrossFade(val value: Boolean) : MangaSettingsUpdate
+    data class ScrollMode(val value: Int) : MangaSettingsUpdate
+    data class PreDownloadNum(val value: Int) : MangaSettingsUpdate
+    data class AutoPageSpeed(val value: Int) : MangaSettingsUpdate
+    data class FooterConfig(val value: String) : MangaSettingsUpdate
+    data class DisableClickScroll(val value: Boolean) : MangaSettingsUpdate
+    data class LongClick(val value: Boolean) : MangaSettingsUpdate
+    data class Background(val value: Int) : MangaSettingsUpdate
+    data class ColorFilter(val value: String) : MangaSettingsUpdate
+    data class HideTitle(val value: Boolean) : MangaSettingsUpdate
+    data class EInk(val enabled: Boolean, val threshold: Int) : MangaSettingsUpdate
+    data class Gray(val enabled: Boolean) : MangaSettingsUpdate
+    data class WebtoonSidePadding(val value: Int) : MangaSettingsUpdate
+    data class VolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
+    data class ReverseVolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
+    data class ClickAction(val area: MangaClickArea, val value: Int) : MangaSettingsUpdate
+}
+
+enum class MangaClickArea { TL, TC, TR, ML, MC, MR, BL, BC, BR }

+ 43 - 0
app/src/main/java/io/legado/app/domain/gateway/OtherConfigAuxiliaryGateways.kt

@@ -0,0 +1,43 @@
+package io.legado.app.domain.gateway
+
+import kotlinx.coroutines.flow.Flow
+
+data class CheckSourceSettings(
+    val timeoutMillis: Long = 180_000L,
+    val checkSearch: Boolean = true,
+    val checkDiscovery: Boolean = true,
+    val checkInfo: Boolean = true,
+    val checkCategory: Boolean = true,
+    val checkContent: Boolean = true,
+)
+
+interface CheckSourceSettingsGateway {
+    val currentSettings: CheckSourceSettings
+    val settings: Flow<CheckSourceSettings>
+
+    suspend fun update(settings: CheckSourceSettings)
+}
+
+data class DirectLinkRule(
+    val uploadUrl: String,
+    val downloadUrlRule: String,
+    val summary: String,
+    val compress: Boolean = false,
+)
+
+interface DirectLinkSettingsGateway {
+    suspend fun loadRule(): DirectLinkRule
+    suspend fun loadDefaultRules(): List<DirectLinkRule>
+    suspend fun saveRule(rule: DirectLinkRule)
+    suspend fun testRule(rule: DirectLinkRule): String
+}
+
+interface LocalPasswordGateway {
+    suspend fun setPassword(password: String?)
+}
+
+interface OtherConfigSystemGateway {
+    fun isProcessTextEnabled(): Boolean
+    suspend fun setProcessTextEnabled(enabled: Boolean)
+    suspend fun clearWebViewData()
+}

+ 6 - 0
app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt

@@ -4,6 +4,7 @@ import io.legado.app.domain.model.settings.OtherSettings
 import kotlinx.coroutines.flow.Flow
 
 interface OtherSettingsGateway {
+    val currentSettings: OtherSettings
     val settings: Flow<OtherSettings>
     suspend fun update(update: OtherSettingsUpdate)
 }
@@ -27,4 +28,9 @@ sealed interface OtherSettingsUpdate {
     data class ProcessText(val value: Boolean) : OtherSettingsUpdate
     data class RecordLog(val value: Boolean) : OtherSettingsUpdate
     data class RecordHeapDump(val value: Boolean) : OtherSettingsUpdate
+    data class AudioPlayUseWakeLock(val value: Boolean) : OtherSettingsUpdate
+    data class ImportKeepName(val value: Boolean) : OtherSettingsUpdate
+    data class ImportKeepGroup(val value: Boolean) : OtherSettingsUpdate
+    data class ImportKeepEnable(val value: Boolean) : OtherSettingsUpdate
+    data class FontSort(val value: Int) : OtherSettingsUpdate
 }

+ 5 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt

@@ -10,6 +10,9 @@ interface ReadAloudSettingsGateway {
 }
 
 sealed interface ReadAloudSettingsUpdate {
+    data class TtsEngine(val value: String?) : ReadAloudSettingsUpdate
+    data class ParagraphInterval(val value: Int) : ReadAloudSettingsUpdate
+    data class AudioCacheCleanTime(val value: Int) : ReadAloudSettingsUpdate
     data class IgnoreAudioFocus(val value: Boolean) : ReadAloudSettingsUpdate
     data class MediaButtonOnExit(val value: Boolean) : ReadAloudSettingsUpdate
     data class ReadAloudByMediaButton(val value: Boolean) : ReadAloudSettingsUpdate
@@ -22,4 +25,6 @@ sealed interface ReadAloudSettingsUpdate {
     data class Timer(val value: Int) : ReadAloudSettingsUpdate
     data class FollowSystem(val value: Boolean) : ReadAloudSettingsUpdate
     data class SpeechRate(val value: Int) : ReadAloudSettingsUpdate
+    data class ContentSelectSpeakMode(val value: Int) : ReadAloudSettingsUpdate
+    data class AudioPreDownloadNum(val value: Int) : ReadAloudSettingsUpdate
 }

+ 7 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt

@@ -4,6 +4,7 @@ import io.legado.app.domain.model.settings.ReadSettings
 import kotlinx.coroutines.flow.Flow
 
 interface ReadSettingsGateway {
+    val currentSettings: ReadSettings
     val settings: Flow<ReadSettings>
 
     suspend fun update(update: ReadSettingsUpdate)
@@ -25,6 +26,8 @@ sealed interface ReadSettingsUpdate {
     data class UseZhLayout(val value: Boolean) : ReadSettingsUpdate
     data class ShowBrightnessView(val value: String) : ReadSettingsUpdate
     data class BrightnessVwPos(val value: String) : ReadSettingsUpdate
+    data class Brightness(val value: Int) : ReadSettingsUpdate
+    data class BrightnessAuto(val value: Boolean) : ReadSettingsUpdate
     data class UseUnderline(val value: Boolean) : ReadSettingsUpdate
     data class ReadSliderMode(val value: String) : ReadSettingsUpdate
     data class DoubleHorizontalPage(val value: String) : ReadSettingsUpdate
@@ -44,7 +47,11 @@ sealed interface ReadSettingsUpdate {
     data class OptimizeRender(val value: Boolean) : ReadSettingsUpdate
     data class DisableReturnKey(val value: Boolean) : ReadSettingsUpdate
     data class ShowReadTitleAddition(val value: Boolean) : ReadSettingsUpdate
+    data class TextSelectMenuConfig(val value: String) : ReadSettingsUpdate
+    data class ReadUrlInBrowser(val value: Boolean) : ReadSettingsUpdate
     data class ShowMenuIcon(val value: Boolean) : ReadSettingsUpdate
     data class PageKeys(val previous: String, val next: String) : ReadSettingsUpdate
     data class FontFolder(val value: String) : ReadSettingsUpdate
+    data class SystemTypefaces(val value: Int) : ReadSettingsUpdate
+    data class PreDownloadNum(val value: Int) : ReadSettingsUpdate
 }

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

@@ -0,0 +1,25 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ReadStyleState
+import kotlinx.coroutines.flow.StateFlow
+import java.io.InputStream
+
+interface ReadStyleGateway {
+    val currentState: ReadStyleState
+    val state: StateFlow<ReadStyleState>
+
+    fun refresh()
+    fun save()
+    fun updateCurrentStyle(mutation: ReadStyleMutation)
+    fun applyPreset(index: Int): Boolean
+    fun addStyle(): Int
+    fun deleteCurrentStyle(): Boolean
+    fun importCurrentStyle(bytes: ByteArray)
+    fun importOrReplaceStyle(bytes: ByteArray): String
+    fun exportCurrentStyle(): ByteArray
+    fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String
+    fun setCurrentBackgroundImage(path: String)
+    fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean)
+    fun exportConfigsJson(): String
+    fun exportShareConfigJson(): String
+}

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

@@ -0,0 +1,107 @@
+package io.legado.app.domain.gateway
+
+sealed interface ReadStyleMutation {
+    data class IntValue(val key: ReadStyleIntKey, val value: Int) : ReadStyleMutation
+    data class FloatValue(val key: ReadStyleFloatKey, val value: Float) : ReadStyleMutation
+    data class BooleanValue(val key: ReadStyleBooleanKey, val value: Boolean) : ReadStyleMutation
+    data class StringValue(val key: ReadStyleStringKey, val value: String) : ReadStyleMutation
+    data class ColorValue(val key: ReadStyleColorKey, val value: Int) : ReadStyleMutation
+    data class Background(val type: Int, val value: String) : ReadStyleMutation
+}
+
+enum class ReadStyleIntKey {
+    TextSize,
+    LineSpacing,
+    ParagraphSpacing,
+    TextBold,
+    TitleMode,
+    TitleBold,
+    TitleLineSpacingExtra,
+    TitleLineSpacingSub,
+    TitleSize,
+    TitleTopSpacing,
+    TitleBottomSpacing,
+    TitleSegType,
+    TitleSegDistance,
+    HeaderMode,
+    FooterMode,
+    TipHeaderLeft,
+    TipHeaderMiddle,
+    TipHeaderRight,
+    TipFooterLeft,
+    TipFooterMiddle,
+    TipFooterRight,
+    HeaderFontSize,
+    PageAnim,
+    UnderlineHeight,
+    UnderlinePadding,
+    PaddingTop,
+    PaddingBottom,
+    PaddingLeft,
+    PaddingRight,
+    HeaderPaddingTop,
+    HeaderPaddingBottom,
+    HeaderPaddingLeft,
+    HeaderPaddingRight,
+    FooterPaddingTop,
+    FooterPaddingBottom,
+    FooterPaddingLeft,
+    FooterPaddingRight,
+    BgType,
+    BgTypeNight,
+    BgTypeEInk,
+    BgAlpha,
+}
+
+enum class ReadStyleFloatKey {
+    LetterSpacing,
+    TitleSegScaling,
+    ShadowRadius,
+    ShadowDx,
+    ShadowDy,
+    DottedBase,
+    DottedRatio,
+}
+
+enum class ReadStyleBooleanKey {
+    TextItalic,
+    TextShadow,
+    Underline,
+    DottedLine,
+    UnderlineExtend,
+    ShowHeaderLine,
+    ShowFooterLine,
+    StatusIconDark,
+}
+
+enum class ReadStyleStringKey {
+    TextFont,
+    ParagraphIndent,
+    TitleFont,
+    TitleSegFlag,
+    HeaderFont,
+    CustomTipHeaderLeft,
+    CustomTipHeaderMiddle,
+    CustomTipHeaderRight,
+    CustomTipFooterLeft,
+    CustomTipFooterMiddle,
+    CustomTipFooterRight,
+    BgStr,
+    BgStrNight,
+    BgStrEInk,
+    StyleName,
+}
+
+enum class ReadStyleColorKey {
+    Text,
+    TextAccent,
+    Title,
+    TitleNight,
+    TipHeader,
+    TipHeaderNight,
+    TipFooter,
+    TipFooterNight,
+    TipDivider,
+    Shadow,
+    Underline,
+}

+ 8 - 0
app/src/main/java/io/legado/app/domain/gateway/ThemePackageSettingsGateway.kt

@@ -0,0 +1,8 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ThemeExportData
+
+interface ThemePackageSettingsGateway {
+    fun exportCurrent(): ThemeExportData
+    suspend fun applyAndAwait(data: ThemeExportData)
+}

+ 2 - 7
app/src/main/java/io/legado/app/domain/gateway/TranslationSettingsGateway.kt

@@ -4,12 +4,7 @@ import io.legado.app.domain.model.settings.TranslationSettings
 import kotlinx.coroutines.flow.Flow
 
 interface TranslationSettingsGateway {
+    val currentSettings: TranslationSettings
     val settings: Flow<TranslationSettings>
-    suspend fun update(update: TranslationSettingsUpdate)
-}
-
-sealed interface TranslationSettingsUpdate {
-    data class Provider(val value: String) : TranslationSettingsUpdate
-    data class TargetLanguage(val value: String) : TranslationSettingsUpdate
-    data class MaxCharsPerChunk(val value: Int) : TranslationSettingsUpdate
+    suspend fun update(transform: (TranslationSettings) -> TranslationSettings)
 }

+ 28 - 0
app/src/main/java/io/legado/app/domain/model/BookGroupMutation.kt

@@ -0,0 +1,28 @@
+package io.legado.app.domain.model
+
+data class NewBookGroup(
+    val groupName: String,
+    val bookSort: Int,
+    val enableRefresh: Boolean,
+    val isPrivate: Boolean,
+    val cover: String?,
+    val pattern: String?,
+)
+
+data class BookGroupUpdate(
+    val groupId: Long,
+    val groupName: String,
+    val cover: String?,
+    val order: Int,
+    val enableRefresh: Boolean,
+    val show: Boolean,
+    val bookSort: Int,
+    val isPrivate: Boolean,
+)
+
+data class TagGroupRuleUpdate(
+    val id: Long,
+    val pattern: String,
+    val groupName: String,
+    val order: Int,
+)

+ 11 - 1
app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt

@@ -4,7 +4,16 @@ data class AppUiConfiguration(
     val language: String = "auto",
     val appShell: AppShellSettings = AppShellSettings(),
     val theme: ThemeSettings = ThemeSettings(),
-)
+    val cover: CoverSettings = CoverSettings(),
+    val isSystemDarkTheme: Boolean = false,
+) {
+    val isDarkTheme: Boolean
+        get() = when (appShell.themeMode) {
+            "1" -> false
+            "2" -> true
+            else -> isSystemDarkTheme
+        }
+}
 
 data class AppUiConfigurationDiff(
     val localeChanged: Boolean = false,
@@ -23,6 +32,7 @@ fun AppUiConfiguration.diffFrom(previous: AppUiConfiguration): AppUiConfiguratio
     AppUiConfigurationDiff(
         localeChanged = language != previous.language,
         themeChanged = appShell.themeMode != previous.appShell.themeMode ||
+            (appShell.themeMode == "0" && isSystemDarkTheme != previous.isSystemDarkTheme) ||
             theme.requiresLegacyThemeRefresh(previous.theme),
         fontScaleChanged = appShell.fontScale != previous.appShell.fontScale,
         windowChanged = appShell.showStatusBar != previous.appShell.showStatusBar ||

+ 14 - 0
app/src/main/java/io/legado/app/domain/model/settings/BookExportSettings.kt

@@ -0,0 +1,14 @@
+package io.legado.app.domain.model.settings
+
+data class BookExportSettings(
+    val bookExportFileName: String? = null,
+    val episodeExportFileName: String = "",
+    val exportCharset: String = "UTF-8",
+    val exportUseReplace: Boolean = true,
+    val exportToWebDav: Boolean = false,
+    val exportNoChapterName: Boolean = false,
+    val enableCustomExport: Boolean = false,
+    val exportType: Int = 0,
+    val exportPictureFile: Boolean = false,
+    val parallelExportBook: Boolean = false,
+)

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt

@@ -4,6 +4,7 @@ import io.legado.app.data.entities.BookGroup
 
 data class BookshelfSettings(
     val bookGroupStyle: Int = 0,
+    val hideEmptyGroups: Boolean = false,
     val bookshelfSort: Int = 0,
     val bookshelfSortOrder: Int = 1,
     val showUnread: Boolean = true,

+ 30 - 0
app/src/main/java/io/legado/app/domain/model/settings/ChangeSourceSettings.kt

@@ -0,0 +1,30 @@
+package io.legado.app.domain.model.settings
+
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+
+data class ChangeSourceSettings(
+    val searchScope: String = "",
+    val checkAuthor: Boolean = false,
+    val loadInfo: Boolean = false,
+    val loadToc: Boolean = false,
+    val loadWordCount: Boolean = false,
+    val migrateChapters: Boolean = true,
+    val migrateReadingProgress: Boolean = true,
+    val migrateGroup: Boolean = true,
+    val migrateCover: Boolean = true,
+    val migrateCategory: Boolean = true,
+    val migrateRemark: Boolean = true,
+    val migrateReadConfig: Boolean = true,
+    val deleteDownloadedChapters: Boolean = false,
+) {
+    fun migrationOptions() = ChangeSourceMigrationOptions(
+        migrateChapters = migrateChapters,
+        migrateReadingProgress = migrateReadingProgress,
+        migrateGroup = migrateGroup,
+        migrateCover = migrateCover,
+        migrateCategory = migrateCategory,
+        migrateRemark = migrateRemark,
+        migrateReadConfig = migrateReadConfig,
+        deleteDownloadedChapters = deleteDownloadedChapters,
+    )
+}

+ 5 - 0
app/src/main/java/io/legado/app/domain/model/settings/HomepageSettings.kt

@@ -0,0 +1,5 @@
+package io.legado.app.domain.model.settings
+
+data class HomepageSettings(
+    val hiddenSourceUrlsJson: String = "",
+)

+ 8 - 0
app/src/main/java/io/legado/app/domain/model/settings/ImportBookSettings.kt

@@ -0,0 +1,8 @@
+package io.legado.app.domain.model.settings
+
+data class ImportBookSettings(
+    val importBookPath: String? = null,
+    val bookImportFileName: String? = null,
+    val localBookImportSort: Int = 0,
+    val remoteServerId: Long = 0L,
+)

+ 37 - 0
app/src/main/java/io/legado/app/domain/model/settings/MangaSettings.kt

@@ -0,0 +1,37 @@
+package io.legado.app.domain.model.settings
+
+data class MangaSettings(
+    val showMangaUi: Boolean = true,
+    val disableMangaScale: Boolean = true,
+    val disableMangaScrollAnimation: Boolean = false,
+    val disableMangaCrossFade: Boolean = false,
+    val scrollMode: Int = 4,
+    val preDownloadNum: Int = 10,
+    val autoPageSpeed: Int = 3,
+    val footerConfig: String = "",
+    val disableClickScroll: Boolean = false,
+    val longClick: Boolean = true,
+    val background: Int = 0xFF000000.toInt(),
+    val colorFilter: String = "",
+    val hideTitle: Boolean = false,
+    val enableEInk: Boolean = false,
+    val eInkThreshold: Int = 150,
+    val enableGray: Boolean = false,
+    val webtoonSidePaddingDp: Int = 0,
+    val volumeKeyPage: Boolean = false,
+    val reverseVolumeKeyPage: Boolean = false,
+    val clickActionTL: Int = -1,
+    val clickActionTC: Int = -1,
+    val clickActionTR: Int = 1,
+    val clickActionML: Int = 2,
+    val clickActionMC: Int = 0,
+    val clickActionMR: Int = 1,
+    val clickActionBL: Int = 2,
+    val clickActionBC: Int = 1,
+    val clickActionBR: Int = 1,
+) {
+    fun hasMenuClickArea(): Boolean =
+        clickActionTL * clickActionTC * clickActionTR *
+            clickActionML * clickActionMC * clickActionMR *
+            clickActionBL * clickActionBC * clickActionBR == 0
+}

+ 5 - 0
app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt

@@ -21,4 +21,9 @@ data class OtherSettings(
     val processText: Boolean = true,
     val recordLog: Boolean = false,
     val recordHeapDump: Boolean = false,
+    val audioPlayUseWakeLock: Boolean = false,
+    val importKeepName: Boolean = false,
+    val importKeepGroup: Boolean = false,
+    val importKeepEnable: Boolean = false,
+    val fontSort: Int = 0,
 )

+ 5 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -1,6 +1,9 @@
 package io.legado.app.domain.model.settings
 
 data class ReadAloudSettings(
+    val ttsEngine: String? = null,
+    val ttsParagraphInterval: Int = 0,
+    val audioCacheCleanTime: Int = 10,
     val ignoreAudioFocus: Boolean = false,
     val mediaButtonOnExit: Boolean = true,
     val readAloudByMediaButton: Boolean = false,
@@ -19,4 +22,6 @@ data class ReadAloudSettings(
     val speechAnalysisMode: String = "rule",
     val useMultiSpeaker: Boolean = true,
     val defaultInterface: String = "classic",
+    val contentSelectSpeakMode: Int = 0,
+    val audioPreDownloadNum: Int = 10,
 )

+ 4 - 1
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -41,12 +41,16 @@ data class ReadSettings(
     val disableReturnKey: Boolean = false,
     val expandTextMenu: Boolean = false,
     val showSelectMenuIcon: Boolean = true,
+    val textSelectMenuConfig: String = "",
     val showReadTitleAddition: Boolean = true,
     val autoReadSpeed: Int = 10,
+    val systemTypefaces: Int = 0,
+    val preDownloadNum: Int = 10,
     val prevKeys: String = "",
     val nextKeys: String = "",
     val tocUiUseReplace: Boolean = false,
     val tocCountWords: Boolean = true,
+    val readUrlInBrowser: Boolean = false,
     val readStyleSelect: Int = 0,
     val comicStyleSelect: Int = 0,
     val shareLayout: Boolean = false,
@@ -101,4 +105,3 @@ data class ReadSettings(
     val chineseConverterType: Int = 0,
     val showMenuIcon: Boolean = true,
 )
-

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

@@ -0,0 +1,20 @@
+package io.legado.app.domain.model.settings
+
+data class ReadStyleState(
+    val items: List<ReadStyleItem> = emptyList(),
+    val selectedIndex: Int = 0,
+    val shareLayout: Boolean = false,
+)
+
+data class ReadStyleItem(
+    val name: String,
+    val bgType: Int,
+    val bgValue: String,
+    val bgTypeNight: Int,
+    val bgValueNight: String,
+    val bgTypeEInk: Int,
+    val bgValueEInk: String,
+    val textColor: Int,
+    val textColorNight: Int,
+    val textColorEInk: Int,
+)

+ 98 - 0
app/src/main/java/io/legado/app/domain/model/settings/ThemeExportData.kt

@@ -0,0 +1,98 @@
+package io.legado.app.domain.model.settings
+
+import androidx.annotation.Keep
+
+@Keep
+data class ThemeExportData(
+    val appTheme: String = "0",
+    val themeMode: String = "0",
+    val isPureBlack: Boolean = false,
+    val composeEngine: String = "material",
+    val paletteStyle: String = "tonalSpot",
+    val materialVersion: String = "material3",
+    val customMode: String? = "tonalSpot",
+    val customContrast: String = "Default",
+    val launcherIcon: String = "ic_launcher",
+    val isPredictiveBackEnabled: Boolean = true,
+    val fontScale: Int = 10,
+    val enableDeepPersonalization: Boolean = false,
+    val cPrimary: Int = 0,
+    val cNPrimary: Int = 0,
+    val themeColor: Int = 0,
+    val secondaryThemeColor: Int = 0,
+    val primaryTextColor: Int = 0,
+    val secondaryTextColor: Int = 0,
+    val themeBackgroundColor: Int = 0,
+    val labelContainerColor: Int = 0,
+    val themeColorNight: Int = 0,
+    val secondaryThemeColorNight: Int = 0,
+    val primaryTextColorNight: Int = 0,
+    val secondaryTextColorNight: Int = 0,
+    val themeBackgroundColorNight: Int = 0,
+    val labelContainerColorNight: Int = 0,
+    val bookInfoInputColor: Int = 0,
+    val bookInfoFollowCoverColor: Boolean = true,
+    val bookInfoBackgroundBlur: String = "on",
+    val bookInfoNetworkCoverBackground: String? = null,
+    val bookInfoDefaultCoverBackground: String? = null,
+    val containerOpacity: Int = 100,
+    val enableItemDivider: Boolean = false,
+    val itemDividerWidth: Float = 1f,
+    val itemDividerLength: Float = 80f,
+    val itemDividerColor: Int = 0,
+    val enableBlur: Boolean = false,
+    val enableProgressiveBlur: Boolean = false,
+    val topBarBlurRadius: Int = 24,
+    val bottomBarBlurRadius: Int = 8,
+    val topBarBlurAlpha: Int = 73,
+    val bottomBarBlurAlpha: Int = 40,
+    val bottomBarLensRadius: Float = 24f,
+    val topBarOpacity: Int = 100,
+    val bottomBarOpacity: Int = 100,
+    val enableCustomTagColors: Boolean = false,
+    val customTagColorsJson: String? = null,
+    val bookshelfCardColor: Int = 0,
+    val bookshelfCardColorDark: Int = 0,
+    val showHome: Boolean = true,
+    val showDiscovery: Boolean = true,
+    val showRss: Boolean = true,
+    val showStatusBar: Boolean = true,
+    val swipeAnimation: Boolean = true,
+    val showBottomView: Boolean = true,
+    val useFloatingBottomBar: Boolean = false,
+    val useFloatingBottomBarLiquidGlass: Boolean = false,
+    val tabletInterface: String = "auto",
+    val labelVisibilityMode: String = "auto",
+    val defaultHomePage: String = "bookshelf",
+    val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
+    val navIconHome: String = "",
+    val navIconBookshelf: String = "",
+    val navIconExplore: String = "",
+    val navIconRss: String = "",
+    val navIconMy: String = "",
+    val useMiuixMonet: Boolean = false,
+    val useFlexibleTopAppBar: Boolean = true,
+    val bgImageLight: String? = null,
+    val bgImageDark: String? = null,
+    val bgImageBlurring: Int = 0,
+    val bgImageNBlurring: Int = 0,
+    val appFontPath: String? = null,
+    val selectedCoverAlbumId: String? = null,
+    val coverLoadOnlyWifi: Boolean = false,
+    val coverUseDefault: Boolean = false,
+    val coverShowShadow: Boolean = false,
+    val coverShowStroke: Boolean = true,
+    val coverDefaultColor: Boolean = true,
+    val coverDefaultImage: String = "",
+    val coverTextColor: Int = -16777216,
+    val coverShadowColor: Int = -16777216,
+    val coverShowName: Boolean = true,
+    val coverShowAuthor: Boolean = true,
+    val coverDefaultImageDark: String = "",
+    val coverTextColorN: Int = -1,
+    val coverShadowColorN: Int = -1,
+    val coverShowNameN: Boolean = true,
+    val coverShowAuthorN: Boolean = true,
+    val coverInfoOrientation: String = "0",
+    val assets: Map<String, String>? = null,
+)

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

@@ -38,6 +38,7 @@ data class ThemeSettings(
     val bookInfoFollowCoverColor: Boolean = true,
     val bookInfoNetworkCoverBackground: String = "on",
     val bookInfoDefaultCoverBackground: String = "on",
+    val bookInfoInputColor: Int = 0,
     val backgroundImageLight: String? = null,
     val backgroundImageDark: String? = null,
     val backgroundImageBlurring: Int = 0,
@@ -89,3 +90,6 @@ fun ThemeSettings.customColors(isDark: Boolean): ThemeCustomColors =
             labelContainer = labelContainerColor,
         )
     }
+
+fun ThemeSettings.hasBackgroundImage(isDark: Boolean): Boolean =
+    if (isDark) !backgroundImageDark.isNullOrBlank() else !backgroundImageLight.isNullOrBlank()

+ 2 - 0
app/src/main/java/io/legado/app/domain/model/settings/TranslationSettings.kt

@@ -6,4 +6,6 @@ data class TranslationSettings(
     val provider: String = TranslationConstants.PROVIDER_GOOGLE,
     val targetLanguage: String = "zh",
     val maxCharsPerChunk: Int = 10000,
+    val concurrentChunks: Int = 1,
+    val retryCount: Int = 2,
 )

+ 11 - 2
app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt

@@ -3,6 +3,8 @@ package io.legado.app.domain.usecase
 import io.legado.app.data.entities.Book
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.NetworkUtils
 import kotlinx.coroutines.Dispatchers
@@ -10,7 +12,9 @@ import kotlinx.coroutines.withContext
 
 class AddBookUseCase(
     private val bookRepository: BookRepository,
-    private val bookSourceRepository: BookSourceRepository
+    private val bookSourceRepository: BookSourceRepository,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
 ) {
     suspend fun execute(
         bookUrls: String,
@@ -55,7 +59,12 @@ class AddBookUseCase(
                 val dbBook = bookRepository.getBook(bookInfo.name, bookInfo.author)
                 if (dbBook != null) {
                     val toc = WebBook.getChapterListAwait(bookSource, bookInfo).getOrThrow()
-                    dbBook.migrateTo(bookInfo, toc)
+                    dbBook.migrateTo(
+                        bookInfo,
+                        toc,
+                        otherSettingsGateway.currentSettings.replaceEnableDefault,
+                        readSettingsGateway.currentSettings.chineseConverterType,
+                    )
                     bookRepository.insert(bookInfo)
                     bookRepository.insertChapters(*toc.toTypedArray())
                 } else {

+ 17 - 3
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -9,6 +9,8 @@ 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.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
@@ -71,6 +73,8 @@ class ChangeBookSourceUseCase(
     private val database: AppDatabase,
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
 ) {
 
     fun applyMigration(
@@ -79,7 +83,13 @@ class ChangeBookSourceUseCase(
         chapters: List<BookChapter>,
         options: ChangeSourceMigrationOptions,
     ): Book {
-        oldBook.applyMigrationTo(newBook, chapters, options)
+        oldBook.applyMigrationTo(
+            newBook,
+            chapters,
+            options,
+            otherSettingsGateway.currentSettings.replaceEnableDefault,
+            readSettingsGateway.currentSettings.chineseConverterType,
+        )
         newBook.removeType(BookType.updateError)
         return newBook
     }
@@ -239,6 +249,8 @@ class ChangeBookSourceUseCase(
         newBook: Book,
         chapters: List<BookChapter>,
         options: ChangeSourceMigrationOptions,
+        defaultReplaceEnabled: Boolean,
+        chineseConverterType: Int,
     ) {
         newBook.totalChapterNum = chapters.size
         if (options.migrateReadingProgress && chapters.isNotEmpty()) {
@@ -247,7 +259,8 @@ class ChangeBookSourceUseCase(
                 .coerceIn(0, chapters.lastIndex)
             newBook.durChapterTitle = chapters[newBook.durChapterIndex].getDisplayTitle(
                 ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
-                getUseReplaceRule()
+                getUseReplaceRule(defaultReplaceEnabled),
+                chineseConverterType = chineseConverterType,
             )
             newBook.durChapterPos = durChapterPos
             newBook.durChapterTime = durChapterTime
@@ -255,7 +268,8 @@ class ChangeBookSourceUseCase(
             newBook.durChapterIndex = 0
             newBook.durChapterTitle = chapters.firstOrNull()?.getDisplayTitle(
                 ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
-                getUseReplaceRule()
+                getUseReplaceRule(defaultReplaceEnabled),
+                chineseConverterType = chineseConverterType,
             )
             newBook.durChapterPos = 0
             newBook.durChapterTime = System.currentTimeMillis()

+ 18 - 11
app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt

@@ -5,14 +5,15 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.gateway.BookSearchGateway
+import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.model.settings.ChangeSourceSettings
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.releaseHtmlData
-import io.legado.app.ui.book.changesource.ChangeSourceConfig
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.book.changesource.ObservableSourceConfig
-import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.internString
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -47,6 +48,8 @@ sealed interface ChangeSourceSearchEvent {
 
 class ChangeSourceSearchUseCase(
     private val gateway: BookSearchGateway,
+    private val changeSourceSettingsGateway: ChangeSourceSettingsGateway,
+    private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
 ) {
     @OptIn(ExperimentalCoroutinesApi::class)
     fun search(
@@ -57,6 +60,7 @@ class ChangeSourceSearchUseCase(
         fromReadBookActivity: Boolean,
     ): Flow<ChangeSourceSearchEvent> = flow {
         val contentProcessor = ContentProcessor.get(oldBook)
+        val settings = changeSourceSettingsGateway.currentSettings
         val bookSourceParts = scope.getBookSourceParts()
         if (bookSourceParts.isEmpty()) {
             throw io.legado.app.exception.NoStackTraceException("启用书源为空")
@@ -67,7 +71,7 @@ class ChangeSourceSearchUseCase(
 
         var processedSources = 0
         var resultCount = 0
-        val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
+        val concurrency = downloadCacheSettingsGateway.currentSettings.threadCount.coerceAtLeast(1)
 
         bookSourceParts.asFlow()
             .mapNotNull { it.getBookSource() }
@@ -77,7 +81,8 @@ class ChangeSourceSearchUseCase(
                         withTimeout(60000L) {
                             searchSource(
                                 source, name, author, oldBook, fromReadBookActivity,
-                                contentProcessor
+                                contentProcessor,
+                                settings,
                             )
                         }
                     } catch (_: Throwable) {
@@ -120,8 +125,9 @@ class ChangeSourceSearchUseCase(
         fromReadBookActivity: Boolean,
     ): Flow<ChangeSourceSearchEvent> = flow {
         val contentProcessor = ContentProcessor.get(oldBook)
+        val settings = changeSourceSettingsGateway.currentSettings
         val totalBooks = books.size
-        val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
+        val concurrency = downloadCacheSettingsGateway.currentSettings.threadCount.coerceAtLeast(1)
         var processedBooks = 0
         var resultCount = 0
 
@@ -136,8 +142,8 @@ class ChangeSourceSearchUseCase(
                                 loadBookInfo(
                                     source = source,
                                     book = searchBook.toBook(),
-                                    loadToc = ChangeSourceConfig.loadToc,
-                                    loadWordCount = ChangeSourceConfig.loadWordCount,
+                                    loadToc = settings.loadToc,
+                                    loadWordCount = settings.loadWordCount,
                                     oldBook = oldBook,
                                     fromReadBookActivity = fromReadBookActivity,
                                     contentProcessor = contentProcessor,
@@ -200,11 +206,12 @@ class ChangeSourceSearchUseCase(
         oldBook: Book,
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
+        settings: ChangeSourceSettings,
     ): List<LoadedSearchBook> {
-        val checkAuthor = ChangeSourceConfig.checkAuthor
-        val loadInfo = ChangeSourceConfig.loadInfo
-        val loadToc = ChangeSourceConfig.loadToc
-        val loadWordCount = ChangeSourceConfig.loadWordCount
+        val checkAuthor = settings.checkAuthor
+        val loadInfo = settings.loadInfo
+        val loadToc = settings.loadToc
+        val loadWordCount = settings.loadWordCount
 
         val resultBooks = WebBook.searchBookAwait(
             source, name,

+ 4 - 3
app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt

@@ -5,8 +5,8 @@ import android.net.Uri
 import io.legado.app.data.entities.Book
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.exception.NoStackTraceException
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
 import io.legado.app.help.http.okHttpClient
@@ -27,7 +27,8 @@ import kotlinx.coroutines.withContext
 class ImportBookshelfUseCase(
     private val context: Context,
     private val bookRepository: BookRepository,
-    private val bookSourceRepository: BookSourceRepository
+    private val bookSourceRepository: BookSourceRepository,
+    private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
 ) {
     suspend fun import(
         str: String,
@@ -69,7 +70,7 @@ class ImportBookshelfUseCase(
     ) {
         onProgress("导入中...")
         val bookSourceParts = bookSourceRepository.getAllEnabledPart()
-        val semaphore = Semaphore(AppConfig.threadCount)
+        val semaphore = Semaphore(downloadCacheSettingsGateway.currentSettings.threadCount)
         val books = GSON.fromJsonArray<Map<String, String?>>(json).getOrThrow()
 
         withContext(Dispatchers.IO) {

+ 2 - 0
app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt

@@ -7,6 +7,7 @@ import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.addType
+import io.legado.app.help.book.applyTagGroupRulesForBook
 import io.legado.app.help.book.isUpError
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.sync
@@ -41,6 +42,7 @@ class RefreshTocUseCase(
         val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
         book.sync(oldBook)
         book.removeType(BookType.updateError)
+        applyTagGroupRulesForBook(book)
         if (book.bookUrl == bookUrl) {
             bookRepository.update(book)
         } else {

+ 20 - 11
app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt

@@ -8,6 +8,7 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
+import io.legado.app.domain.gateway.TranslationSettingsGateway
 import io.legado.app.domain.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
@@ -23,7 +24,6 @@ import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.http.newCallStrResponse
 import io.legado.app.help.http.okHttpClient
-import io.legado.app.ui.config.translation.TranslationConfig
 import io.legado.app.utils.GSON
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -37,7 +37,8 @@ class TranslateChapterUseCase(
     private val aiTextGateway: AiTextGateway,
     private val translationCacheGateway: TranslationCacheGateway,
     private val dictionaryGateway: DictionaryGateway,
-    private val aiProfileGateway: AiProfileGateway
+    private val aiProfileGateway: AiProfileGateway,
+    private val translationSettingsGateway: TranslationSettingsGateway,
 ) {
 
     data class TranslationProgress(
@@ -60,14 +61,15 @@ class TranslateChapterUseCase(
         onTranslateStarted: () -> Unit
     ): Result<String> = withContext(Dispatchers.IO) {
         try {
-            val provider = TranslationConfig.llmProvider
+            val settings = translationSettingsGateway.currentSettings
+            val provider = settings.provider
             val preset = if (provider == TranslationConstants.PROVIDER_APP_AI) {
                 resolveTranslationPreset()
                     ?: return@withContext Result.failure(Exception("No AI translation preset configured"))
             } else {
                 null
             }
-            val targetLanguage = TranslationConfig.llmTargetLanguage
+            val targetLanguage = settings.targetLanguage
 
             val originalContent = BookHelp.getContent(book, bookChapter)
                 ?: return@withContext Result.failure(Exception("Failed to read original content"))
@@ -99,7 +101,7 @@ class TranslateChapterUseCase(
 
             val chunks = ContentChunker.chunk(
                 originalContent,
-                TranslationConfig.llmMaxCharsPerChunk.coerceAtLeast(1000)
+                settings.maxCharsPerChunk.coerceAtLeast(1000)
             )
             if (chunks.isEmpty()) {
                 return@withContext Result.failure(Exception("Failed to chunk content"))
@@ -149,13 +151,16 @@ class TranslateChapterUseCase(
             onTranslateStarted()
             var translationError: Throwable? = null
             coroutineScope {
-                val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4)
+                val concurrentChunks = settings.concurrentChunks.coerceIn(1, 4)
                 val chunkGroups = pendingChunks.chunked(concurrentChunks)
 
                 for ((groupIndex, group) in chunkGroups.withIndex()) {
                     val results = group.map { chunk ->
                         async {
-                            translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, provider, preset, dictionaries, onDictionaryUpdate)
+                            translateAndCacheChunk(
+                                chunk, book, bookChapter, targetLanguage, contentHash, provider,
+                                preset, dictionaries, onDictionaryUpdate, settings.retryCount
+                            )
                         }
                     }.awaitAll()
 
@@ -243,7 +248,8 @@ class TranslateChapterUseCase(
         provider: String,
         preset: AiTaskPresetConfig?,
         dictionaries: MutableList<DictPair>,
-        onDictionaryUpdate: (List<DictPair>) -> Unit
+        onDictionaryUpdate: (List<DictPair>) -> Unit,
+        retryCount: Int,
     ): Result<String> {
         val existingCache =
             translationCacheGateway.getCachedChunk(book, bookChapter, targetLanguage, chunk.index)
@@ -251,7 +257,9 @@ class TranslateChapterUseCase(
             return Result.success(existingCache.translatedChunkContent)
         }
 
-        val result = translateChunkWithRetry(chunk, targetLanguage, provider, preset, dictionaries, onDictionaryUpdate)
+        val result = translateChunkWithRetry(
+            chunk, targetLanguage, provider, preset, dictionaries, onDictionaryUpdate, retryCount
+        )
         if (result.isSuccess) {
             translationCacheGateway.saveChunk(
                 book, bookChapter, targetLanguage,
@@ -277,11 +285,12 @@ class TranslateChapterUseCase(
         provider: String,
         preset: AiTaskPresetConfig?,
         dictionaries: MutableList<DictPair>,
-        onDictionaryUpdate: (List<DictPair>) -> Unit
+        onDictionaryUpdate: (List<DictPair>) -> Unit,
+        retryCount: Int,
     ): Result<String> {
         var lastError: Exception? = null
         var lastRetryReason: RetryReason? = null
-        for (attempt in 0..TranslationConfig.llmRetryCount.coerceIn(0, 5)) {
+        for (attempt in 0..retryCount.coerceIn(0, 5)) {
             val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() }
             val result = when (provider) {
                 TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(chunk.content, targetLanguage)

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

@@ -231,7 +231,7 @@ interface JsExtensions : JsEncodeUtils {
                 url = url,
                 html = html,
                 javaScript = js,
-                headerMap = getSource()?.getHeaderMap(true),
+                headerMap = getSource()?.getHeaderMap(AppConfig.userAgent, true),
                 tag = getSource()?.getKey()
             ).getStrResponse().body
         }
@@ -270,7 +270,7 @@ interface JsExtensions : JsEncodeUtils {
                 url = url,
                 html = html,
                 javaScript = js,
-                headerMap = getSource()?.getHeaderMap(true),
+                headerMap = getSource()?.getHeaderMap(AppConfig.userAgent, true),
                 tag = getSource()?.getKey(),
                 sourceRegex = sourceRegex,
                 delayTime = delayTime
@@ -316,7 +316,7 @@ interface JsExtensions : JsEncodeUtils {
                 url = url,
                 html = html,
                 javaScript = js,
-                headerMap = getSource()?.getHeaderMap(true),
+                headerMap = getSource()?.getHeaderMap(AppConfig.userAgent, true),
                 tag = getSource()?.getKey(),
                 overrideUrlRegex = overrideUrlRegex,
                 delayTime = delayTime

+ 32 - 11
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -11,10 +11,14 @@ import io.legado.app.constant.AppPattern
 import io.legado.app.constant.BookSourceType
 import io.legado.app.constant.BookType
 import io.legado.app.data.appDb
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.HighlightTagRule
+import io.legado.app.data.entities.TagGroupRule
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.RuleBigDataHelp
 import io.legado.app.help.config.AppConfig
@@ -408,7 +412,23 @@ fun parseHighlightedTags(
 
 fun applyTagGroupRules(
     books: List<Book>,
-    rules: List<io.legado.app.data.entities.TagGroupRule>,
+    rules: List<TagGroupRule>,
+) {
+    appDb.runInTransaction {
+        applyTagGroupRules(
+            books = books,
+            rules = rules,
+            groupDao = appDb.bookGroupDao,
+            bookDao = appDb.bookDao,
+        )
+    }
+}
+
+fun applyTagGroupRules(
+    books: List<Book>,
+    rules: List<TagGroupRule>,
+    groupDao: BookGroupDao,
+    bookDao: BookDao,
 ) {
     if (rules.isEmpty()) return
 
@@ -422,9 +442,6 @@ fun applyTagGroupRules(
     }
     if (compiledRules.isEmpty()) return
 
-    val groupDao = appDb.bookGroupDao
-    val bookDao = appDb.bookDao
-
     // Resolve groupName -> groupId (find or create BookGroup)
     val groupCache = mutableMapOf<String, Long>()
     for ((rule, _) in compiledRules) {
@@ -433,7 +450,7 @@ fun applyTagGroupRules(
             val groupId = existing?.groupId ?: run {
                 val newId = groupDao.getUnusedId()
                 groupDao.insert(
-                    io.legado.app.data.entities.BookGroup(
+                    BookGroup(
                         groupId = newId,
                         groupName = rule.groupName,
                     )
@@ -466,9 +483,7 @@ fun applyTagGroupRules(
     }
 
     if (updatedBooks.isNotEmpty()) {
-        appDb.runInTransaction {
-            bookDao.update(*updatedBooks.toTypedArray())
-        }
+        bookDao.update(*updatedBooks.toTypedArray())
     }
 }
 
@@ -528,7 +543,11 @@ fun Book.sync(oldBook: Book) {
         durChapterIndex = curBook.durChapterIndex
         val replaceRules = ContentProcessor.get(this).getTitleReplaceRules()
         appDb.bookChapterDao.getChapter(bookUrl, durChapterIndex)?.let {
-            durChapterTitle = it.getDisplayTitle(replaceRules, getUseReplaceRule())
+            durChapterTitle = it.getDisplayTitle(
+                replaceRules,
+                getUseReplaceRule(AppConfig.replaceEnableDefault),
+                chineseConverterType = AppConfig.chineseConverterType,
+            )
         }
     }
     canUpdate = curBook.canUpdate
@@ -601,8 +620,10 @@ fun Book.isSameNameAuthor(other: Any?): Boolean {
     return false
 }
 
-fun Book.getExportFileName(suffix: String): String {
-    val template = AppConfig.bookExportFileName
+fun Book.getExportFileName(
+    suffix: String,
+    template: String? = AppConfig.bookExportFileName,
+): String {
     if (template.isNullOrBlank()) {
         return "$name 作者:${getRealAuthor()}.$suffix"
     }

+ 6 - 4
app/src/main/java/io/legado/app/help/book/ContentProcessor.kt

@@ -115,11 +115,12 @@ class ContentProcessor private constructor(
                 if (matcher.find()) {
                     mContent = mContent.substring(matcher.end())
                     sameTitleRemoved = true
-                } else if (useReplace && book.getUseReplaceRule()) {
+                } else if (useReplace && book.getUseReplaceRule(AppConfig.replaceEnableDefault)) {
                     title = Pattern.quote(
                         chapter.getDisplayTitle(
                             contentReplaceRules,
-                            chineseConvert = false
+                            chineseConvert = false,
+                            chineseConverterType = AppConfig.chineseConverterType,
                         )
                     )
                     matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
@@ -155,7 +156,7 @@ class ContentProcessor private constructor(
                     placeholder
                 }
             }
-            if (useReplace && book.getUseReplaceRule()) {
+            if (useReplace && book.getUseReplaceRule(AppConfig.replaceEnableDefault)) {
                 val replaceBook = book.toSearchBook()
                 //替换
                 effectiveReplaceRules = arrayListOf()
@@ -208,7 +209,8 @@ class ContentProcessor private constructor(
             //重新添加标题
             mContent = chapter.getDisplayTitle(
                 getTitleReplaceRules(),
-                useReplace = useReplace && book.getUseReplaceRule()
+                useReplace = useReplace && book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                chineseConverterType = AppConfig.chineseConverterType,
             ) + "\n" + mContent
         }
         if (isAndroid8) {

+ 113 - 768
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -1,780 +1,125 @@
 package io.legado.app.help.config
 
-import io.legado.app.BuildConfig
-import io.legado.app.constant.PreferKey
-import io.legado.app.data.appDb
-import io.legado.app.ui.config.backupConfig.BackupConfig
-import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
-import io.legado.app.ui.config.coverConfig.CoverConfig
-import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfig
-import io.legado.app.ui.config.importBookConfig.ImportBookConfig
-import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
-import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
-import io.legado.app.utils.getPrefBoolean
-import io.legado.app.utils.getPrefInt
-import io.legado.app.utils.getPrefString
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.BookExportSettingsGateway
+import io.legado.app.domain.gateway.BookshelfSettingsGateway
+import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.gateway.ImportBookSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.utils.isNightMode
-import io.legado.app.utils.putPrefBoolean
-import io.legado.app.utils.putPrefInt
-import io.legado.app.utils.putPrefString
 import io.legado.app.utils.sysConfiguration
-import splitties.init.appCtx
 
-@Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
+/**
+ * 已废弃的同步只读配置门面。
+ *
+ * 新代码必须注入具体 Gateway;此对象只保留给尚未迁移的 View、服务和启动逻辑。
+ */
+@Deprecated("使用具体 SettingsGateway.currentSettings")
 object AppConfig {
-    val isCronet get() = DownloadCacheConfig.cronetEnable
-    val useAntiAlias get() = OtherConfig.antiAlias
-    val userAgent: String get() = DownloadCacheConfig.userAgent
-
-    val isEInkMode get() = appCtx.getPrefString("app_theme", "0") == "4"
-    val isTransparent get() = appCtx.getPrefString("app_theme", "0") == "13"
-    val customMode get() = ThemeConfig.customMode
-    val clickActionTL get() = appCtx.getPrefInt(PreferKey.clickActionTL, 2)
-    val clickActionTC get() = appCtx.getPrefInt(PreferKey.clickActionTC, 2)
-    val clickActionTR get() = appCtx.getPrefInt(PreferKey.clickActionTR, 1)
-    val clickActionML get() = appCtx.getPrefInt(PreferKey.clickActionML, 2)
-    val clickActionMC get() = appCtx.getPrefInt(PreferKey.clickActionMC, 0)
-    val clickActionMR get() = appCtx.getPrefInt(PreferKey.clickActionMR, 1)
-    val clickActionBL get() = appCtx.getPrefInt(PreferKey.clickActionBL, 2)
-    val clickActionBC get() = appCtx.getPrefInt(PreferKey.clickActionBC, 1)
-    val clickActionBR get() = appCtx.getPrefInt(PreferKey.clickActionBR, 1)
-
-    //    -1无操作 1下一页 2上一页 0显示菜单
-    var mangaClickActionTL
-        get() = ReadMangaConfig.mangaClickActionTL
-        set(value) { ReadMangaConfig.mangaClickActionTL = value }
-    var mangaClickActionTC
-        get() = ReadMangaConfig.mangaClickActionTC
-        set(value) { ReadMangaConfig.mangaClickActionTC = value }
-    var mangaClickActionTR
-        get() = ReadMangaConfig.mangaClickActionTR
-        set(value) { ReadMangaConfig.mangaClickActionTR = value }
-    var mangaClickActionML
-        get() = ReadMangaConfig.mangaClickActionML
-        set(value) { ReadMangaConfig.mangaClickActionML = value }
-    var mangaClickActionMC
-        get() = ReadMangaConfig.mangaClickActionMC
-        set(value) { ReadMangaConfig.mangaClickActionMC = value }
-    var mangaClickActionMR
-        get() = ReadMangaConfig.mangaClickActionMR
-        set(value) { ReadMangaConfig.mangaClickActionMR = value }
-    var mangaClickActionBL
-        get() = ReadMangaConfig.mangaClickActionBL
-        set(value) { ReadMangaConfig.mangaClickActionBL = value }
-    var mangaClickActionBC
-        get() = ReadMangaConfig.mangaClickActionBC
-        set(value) { ReadMangaConfig.mangaClickActionBC = value }
-    var mangaClickActionBR
-        get() = ReadMangaConfig.mangaClickActionBR
-        set(value) { ReadMangaConfig.mangaClickActionBR = value }
-
-    val AppTheme get() = ThemeConfig.appTheme
-
-    val swipeAnimation get() = ThemeConfig.swipeAnimation
-
-    val useDefaultCover get() = CoverConfig.useDefaultCover
-    var optimizeRender
-        get() = CanvasRecorderFactory.isSupport && ReadConfig.optimizeRender
-        set(value) {
-            ReadConfig.optimizeRender = value
-        }
-    val recordLog get() = OtherConfig.recordLog
-    val webServiceAutoStart get() = OtherConfig.webServiceAutoStart
-
-    // -- lyc 版本特性 --
-    val adaptSpecialStyle get() = ReadConfig.adaptSpecialStyle
-    val useUnderline get() = ReadConfig.useUnderline
+    private lateinit var shellGateway: AppShellSettingsGateway
+    private lateinit var themeGateway: ThemeSettingsGateway
+    private lateinit var bookshelfGateway: BookshelfSettingsGateway
+    private lateinit var otherGateway: OtherSettingsGateway
+    private lateinit var backupGateway: BackupSettingsGateway
+    private lateinit var cacheGateway: DownloadCacheSettingsGateway
+    private lateinit var coverGateway: CoverSettingsGateway
+    private lateinit var readGateway: ReadSettingsGateway
+    private lateinit var aloudGateway: ReadAloudSettingsGateway
+    private lateinit var importBookGateway: ImportBookSettingsGateway
+    private lateinit var exportGateway: BookExportSettingsGateway
+
+    internal fun initialize(
+        shellGateway: AppShellSettingsGateway,
+        themeGateway: ThemeSettingsGateway,
+        bookshelfGateway: BookshelfSettingsGateway,
+        otherGateway: OtherSettingsGateway,
+        backupGateway: BackupSettingsGateway,
+        cacheGateway: DownloadCacheSettingsGateway,
+        coverGateway: CoverSettingsGateway,
+        readGateway: ReadSettingsGateway,
+        aloudGateway: ReadAloudSettingsGateway,
+        importBookGateway: ImportBookSettingsGateway,
+        exportGateway: BookExportSettingsGateway,
+    ) {
+        this.shellGateway = shellGateway
+        this.themeGateway = themeGateway
+        this.bookshelfGateway = bookshelfGateway
+        this.otherGateway = otherGateway
+        this.backupGateway = backupGateway
+        this.cacheGateway = cacheGateway
+        this.coverGateway = coverGateway
+        this.readGateway = readGateway
+        this.aloudGateway = aloudGateway
+        this.importBookGateway = importBookGateway
+        this.exportGateway = exportGateway
+    }
 
-    var isNightTheme: Boolean
-        get() = when (ThemeConfig.themeMode) {
+    private val shell get() = shellGateway.currentSettings
+    private val theme get() = themeGateway.currentSettings
+    private val bookshelf get() = bookshelfGateway.currentSettings
+    private val other get() = otherGateway.currentSettings
+    private val backup get() = backupGateway.currentSettings
+    private val cache get() = cacheGateway.currentSettings
+    private val cover get() = coverGateway.currentSettings
+    private val read get() = readGateway.currentSettings
+    private val aloud get() = aloudGateway.currentSettings
+    private val importBook get() = importBookGateway.currentSettings
+    private val export get() = exportGateway.currentSettings
+
+    val isCronet get() = cache.cronetEnabled
+    val userAgent get() = cache.userAgent
+    val isEInkMode get() = theme.appTheme == "4"
+    val customMode get() = theme.customMode
+    val useDefaultCover get() = cover.useDefaultCover
+    val recordLog get() = other.recordLog
+    val recordHeapDump get() = other.recordHeapDump
+    val webServiceAutoStart get() = other.webServiceAutoStart
+    val adaptSpecialStyle get() = read.adaptSpecialStyle
+    val isNightTheme: Boolean
+        get() = when (shell.themeMode) {
             "1" -> false
             "2" -> true
             else -> sysConfiguration.isNightMode
         }
-        set(value) {
-            val newMode = if (value) "2" else "1"
-            if (ThemeConfig.themeMode != newMode) {
-                ThemeConfig.themeMode = newMode
-            }
-        }
-
-    var showUnread: Boolean
-        get() = BookshelfConfig.showUnread
-        set(value) {
-            BookshelfConfig.showUnread = value
-        }
-
-    var showTip: Boolean
-        get() = BookshelfConfig.showTip
-        set(value) {
-            BookshelfConfig.showTip = value
-        }
-
-    var showUnreadNew: Boolean
-        get() = BookshelfConfig.showUnreadNew
-        set(value) {
-            BookshelfConfig.showUnreadNew = value
-        }
-
-    var showLastUpdateTime: Boolean
-        get() = BookshelfConfig.showLastUpdateTime
-        set(value) {
-            BookshelfConfig.showLastUpdateTime = value
-        }
-
-    var showWaitUpCount: Boolean
-        get() = BookshelfConfig.showWaitUpCount
-        set(value) {
-            BookshelfConfig.showWaitUpCount = value
-        }
-
-    var readBrightness: Int
-        get() = if (isNightTheme) {
-            appCtx.getPrefInt(PreferKey.nightBrightness, 100)
-        } else {
-            appCtx.getPrefInt(PreferKey.brightness, 100)
-        }
-        set(value) {
-            if (isNightTheme) {
-                appCtx.putPrefInt(PreferKey.nightBrightness, value)
-            } else {
-                appCtx.putPrefInt(PreferKey.brightness, value)
-            }
-        }
-
-    val textSelectAble: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.textSelectAble, true)
-
-//    val isTransparentStatusBar: Boolean
-//        get() = appCtx.getPrefBoolean(PreferKey.transparentStatusBar, true)
-//
-//    val immNavigationBar: Boolean
-//        get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true)
-
-    val screenOrientation: String?
-        get() = ReadConfig.screenOrientation
-
-    var bookGroupStyle: Int
-        get() = BookshelfConfig.bookGroupStyle
-        set(value) {
-            BookshelfConfig.bookGroupStyle = value
-        }
-
-    var bookshelfLayoutModePortrait: Int
-        get() = BookshelfConfig.bookshelfLayoutModePortrait
-        set(value) {
-            BookshelfConfig.bookshelfLayoutModePortrait = value
-        }
-
-    var bookshelfLayoutModeLandscape: Int
-        get() = BookshelfConfig.bookshelfLayoutModeLandscape
-        set(value) {
-            BookshelfConfig.bookshelfLayoutModeLandscape = value
-        }
-
-    var bookshelfLayoutGridPortrait: Int
-        get() = BookshelfConfig.bookshelfLayoutGridPortrait
-        set(value) {
-            BookshelfConfig.bookshelfLayoutGridPortrait = value
-        }
-
-    var bookshelfLayoutGridLandscape: Int
-        get() = BookshelfConfig.bookshelfLayoutGridLandscape
-        set(value) {
-            BookshelfConfig.bookshelfLayoutGridLandscape = value
-        }
-
-    var bookExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.bookExportFileName)
-        set(value) {
-            appCtx.putPrefString(PreferKey.bookExportFileName, value)
-        }
-
-    // 保存 自定义导出章节模式 文件名js表达式
-    var episodeExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.episodeExportFileName)
-        set(value) {
-            appCtx.putPrefString(PreferKey.episodeExportFileName, value)
-        }
-
-    var bookImportFileName: String?
-        get() = ImportBookConfig.bookImportFileName
-        set(value) {
-            ImportBookConfig.bookImportFileName = value
-        }
-
-    var backupPath: String?
-        get() = BackupConfig.backupPath
-        set(value) {
-            BackupConfig.backupPath = value
-        }
-
-    val showHome: Boolean
-        get() = ThemeConfig.showHome
-
-    val showDiscovery: Boolean
-        get() = ThemeConfig.showDiscovery
-
-    val showRSS: Boolean
-        get() = ThemeConfig.showRss
-
-    val showStatusBar: Boolean
-        get() = ThemeConfig.showStatusBar
-
-    val showBottomView: Boolean
-        get() = ThemeConfig.showBottomView
-
-    val autoRefreshBook: Boolean
-        get() = ThemeConfig.autoRefreshBook
-
-    var enableReview: Boolean
-        get() = BuildConfig.DEBUG && appCtx.getPrefBoolean(PreferKey.enableReview, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.enableReview, value)
-        }
-
-    var threadCount: Int
-        get() = DownloadCacheConfig.threadCount
-        set(value) {
-            DownloadCacheConfig.threadCount = value
-        }
-
-    // 添加本地选择的目录
-    var importBookPath: String?
-        get() = ImportBookConfig.importBookPath
-        set(value) {
-            ImportBookConfig.importBookPath = value
-        }
-
-    var ttsFlowSys: Boolean
-        get() = ReadConfig.ttsFollowSys
-        set(value) {
-            ReadConfig.ttsFollowSys = value
-        }
-
-    val noAnimScrollPage: Boolean
-        get() = ReadConfig.noAnimScrollPage
-
-    const val defaultSpeechRate = 5
-
-    var ttsSpeechRate: Int
-        get() = ReadConfig.ttsSpeechRate
-        set(value) {
-            ReadConfig.ttsSpeechRate = value
-        }
-
-    var ttsTimer: Int
-        get() = ReadConfig.ttsTimer
-        set(value) {
-            ReadConfig.ttsTimer = value
-        }
-
-    val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate
-
-    val chineseConverterType: Int
-        get() = ReadConfig.chineseConverterType
-
-    var systemTypefaces: Int
-        get() = appCtx.getPrefInt(PreferKey.systemTypefaces)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.systemTypefaces, value)
-        }
-
-//    var elevation: Int
-//        get() = if (isEInkMode) 0 else appCtx.getPrefInt(
-//            PreferKey.barElevation,
-//            AppConst.sysElevation
-//        )
-//        set(value) {
-//            appCtx.putPrefInt(PreferKey.barElevation, value)
-//        }
-
-    var readUrlInBrowser: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.readUrlOpenInBrowser)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.readUrlOpenInBrowser, value)
-        }
-
-    var exportCharset: String
-        get() {
-            val c = appCtx.getPrefString(PreferKey.exportCharset)
-            if (c.isNullOrBlank()) {
-                return "UTF-8"
-            }
-            return c
-        }
-        set(value) {
-            appCtx.putPrefString(PreferKey.exportCharset, value)
-        }
-
-    var exportUseReplace: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.exportUseReplace, true)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.exportUseReplace, value)
-        }
-
-    var exportToWebDav: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.exportToWebDav)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.exportToWebDav, value)
-        }
-    var exportNoChapterName: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.exportNoChapterName)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value)
-        }
-
-    // 是否启用自定义导出 default->false
-    var enableCustomExport: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.enableCustomExport, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.enableCustomExport, value)
-        }
-
-    var exportType: Int
-        get() = appCtx.getPrefInt(PreferKey.exportType)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.exportType, value)
-        }
-    var exportPictureFile: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.exportPictureFile, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.exportPictureFile, value)
-        }
-
-    var parallelExportBook: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.parallelExportBook, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.parallelExportBook, value)
-        }
-
-    var ttsEngine: String?
-        get() = io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine
-        set(value) {
-            io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine = value
-        }
-
-    var webPort: Int
-        get() = OtherConfig.webPort
-        set(value) {
-            OtherConfig.webPort = value
-        }
-
-    var tocUiUseReplace: Boolean
-        get() = ReadConfig.tocUiUseReplace
-        set(value) {
-            ReadConfig.tocUiUseReplace = value
-        }
-
-    var tocCountWords: Boolean
-        get() = ReadConfig.tocCountWords
-        set(value) {
-            ReadConfig.tocCountWords = value
-        }
-
-    val autoChangeSource: Boolean
-        get() = ReadConfig.autoChangeSource
-
-    var openBookInfoByClickTitle: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.openBookInfoByClickTitle, value)
-        }
-
-    var showBookshelfFastScroller: Boolean
-        get() = BookshelfConfig.showBookshelfFastScroller
-        set(value) {
-            BookshelfConfig.showBookshelfFastScroller = value
-        }
-
-    var contentSelectSpeakMod: Int
-        get() = appCtx.getPrefInt(PreferKey.contentSelectSpeakMod)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.contentSelectSpeakMod, value)
-        }
-
-    var batchChangeSourceDelay: Int
-        get() = appCtx.getPrefInt(PreferKey.batchChangeSourceDelay)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.batchChangeSourceDelay, value)
-        }
-
-    var importKeepName: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.importKeepName)
-        set(value) { appCtx.putPrefBoolean(PreferKey.importKeepName, value) }
-    var importKeepGroup: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.importKeepGroup)
-        set(value) { appCtx.putPrefBoolean(PreferKey.importKeepGroup, value) }
-    var importKeepEnable: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.importKeepEnable, value)
-        }
-
-    var previewImageByClick: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.previewImageByClick, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.previewImageByClick, value)
-        }
-
-    val clickImgWay: String?
-        get() = ReadConfig.clickImgWay
-    var preDownloadNum
-        get() = DownloadCacheConfig.preDownloadNum
-        set(value) {
-            DownloadCacheConfig.preDownloadNum = value
-        }
-
-    val syncBookProgress get() = BackupConfig.syncBookProgress
-
-    val syncBookProgressPlus get() = BackupConfig.syncBookProgressPlus
-
-    val mediaButtonOnExit get() = appCtx.getPrefBoolean(PreferKey.mediaButtonOnExit, true)
-
-    val readAloudByMediaButton
-        get() = appCtx.getPrefBoolean(PreferKey.readAloudByMediaButton, false)
-
-    val replaceEnableDefault get() = OtherConfig.replaceEnableDefault
-
-    val webDavDir get() = BackupConfig.webDavDir
-
-    val webDavDeviceName get() = BackupConfig.webDavDeviceName
-
-    val recordHeapDump get() = OtherConfig.recordHeapDump
-
-    val showAddToShelfAlert get() = OtherConfig.showAddToShelfAlert
-
-    var ignoreAudioFocus
-        get() = ReadConfig.ignoreAudioFocus
-        set(value) {
-            ReadConfig.ignoreAudioFocus = value
-        }
-
-    var pauseReadAloudWhilePhoneCalls
-        get() = ReadConfig.pauseReadAloudWhilePhoneCalls
-        set(value) {
-            ReadConfig.pauseReadAloudWhilePhoneCalls = value
-        }
-
-    val onlyLatestBackup get() = BackupConfig.onlyLatestBackup
-
-    val autoCheckNewBackup get() = ThemeConfig.autoCheckNewBackup
-
-    val defaultHomePage get() = ThemeConfig.defaultHomePage
-
-    val updateToVariant get() = OtherConfig.updateToVariant
-
-    var streamReadAloudAudio
-        get() = ReadConfig.streamReadAloudAudio
-        set(value) {
-            ReadConfig.streamReadAloudAudio = value
-        }
-
-    val doublePageHorizontal: String?
-        get() = ReadConfig.doubleHorizontalPage
-
-    val progressBarBehavior: String?
-        get() = ReadConfig.progressBarBehavior
-
-    val keyPageOnLongPress
-        get() = ReadConfig.keyPageOnLongPress
-
-    val volumeKeyPage
-        get() = ReadConfig.volumeKeyPage
-
-    val volumeKeyPageOnPlay
-        get() = ReadConfig.volumeKeyPageOnPlay
-
-    val mouseWheelPage
-        get() = ReadConfig.mouseWheelPage
-
-    val paddingDisplayCutouts
-        get() = ReadConfig.paddingDisplayCutouts
-
-    var pageTouchSlop: Int
-        get() = ReadConfig.pageTouchSlop
-        set(value) {
-            ReadConfig.pageTouchSlop = value
-        }
-
-    var bookshelfSort: Int
-        get() = BookshelfConfig.bookshelfSort
-        set(value) {
-            BookshelfConfig.bookshelfSort = value
-        }
-
-    var bookshelfSortOrder: Int
-        get() = BookshelfConfig.bookshelfSortOrder
-        set(value) {
-            BookshelfConfig.bookshelfSortOrder = value
-        }
-
-    fun getBookSortByGroupId(groupId: Long): Int {
-        return appDb.bookGroupDao.getByID(groupId)?.getRealBookSort()
-            ?: bookshelfSort
-    }
-
-    var bitmapCacheSize: Int
-        get() = DownloadCacheConfig.bitmapCacheSize
-        set(value) {
-            DownloadCacheConfig.bitmapCacheSize = value
-        }
-
-    var imageRetainNum: Int
-        get() = DownloadCacheConfig.imageRetainNum
-        set(value) {
-            DownloadCacheConfig.imageRetainNum = value
-        }
-
-    var showReadTitleBarAddition: Boolean
-        get() = ReadConfig.showReadTitleAddition
-        set(value) {
-            ReadConfig.showReadTitleAddition = value
-        }
-
-    var readBarStyleFollowPage: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value)
-        }
-
-    var readBarStyle: Int
-        get() = appCtx.getPrefInt(PreferKey.readBarStyle, 0)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.readBarStyle, value.coerceIn(0, 2))
-        }
-
-    var sourceEditMaxLine: Int
-        get() = OtherConfig.sourceEditMaxLine
-        set(value) {
-            OtherConfig.sourceEditMaxLine = value
-        }
-
-    var audioPlayUseWakeLock: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.audioPlayWakeLock)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.audioPlayWakeLock, value)
-        }
-
-    var brightnessVwPos: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.brightnessVwPos)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.brightnessVwPos, value)
-        }
-
-    var firebaseEnable: Boolean
-        get() = OtherConfig.firebaseEnable
-        set(value) {
-            OtherConfig.firebaseEnable = value
-        }
-
-    //跳转到漫画界面不使用富文本模式
-    val showMangaUi: Boolean
-        get() = ReadMangaConfig.showMangaUi
-
-    //禁用漫画缩放
-    var disableMangaScale: Boolean
-        get() = ReadMangaConfig.disableMangaScale
-        set(value) { ReadMangaConfig.disableMangaScale = value }
-
-    var disableMangaScrollAnimation: Boolean
-        get() = ReadMangaConfig.disableMangaScrollAnimation
-        set(value) { ReadMangaConfig.disableMangaScrollAnimation = value }
-
-    var disableMangaCrossFade: Boolean
-        get() = ReadMangaConfig.disableMangaCrossFade
-        set(value) { ReadMangaConfig.disableMangaCrossFade = value }
-
-    var titleBarMode
-        get() = ReadConfig.titleBarMode
-        set(value) {
-            ReadConfig.titleBarMode = value
-        }
-
-    //漫画预加载数量
-    var mangaPreDownloadNum
-        get() = ReadMangaConfig.mangaPreDownloadNum
-        set(value) { ReadMangaConfig.mangaPreDownloadNum = value }
-
-    //点击翻页
-    var disableClickScroll
-        get() = ReadMangaConfig.disableClickScroll
-        set(value) { ReadMangaConfig.disableClickScroll = value }
-
-    //漫画滚动速度
-    var mangaAutoPageSpeed
-        get() = ReadMangaConfig.mangaAutoPageSpeed
-        set(value) { ReadMangaConfig.mangaAutoPageSpeed = value }
-
-    //漫画页脚配置
-    var mangaFooterConfig
-        get() = ReadMangaConfig.mangaFooterConfig
-        set(value) { ReadMangaConfig.mangaFooterConfig = value }
-
-    //漫画滚动方式
-    var mangaScrollMode: Int
-        get() = ReadMangaConfig.mangaScrollMode
-        set(value) { ReadMangaConfig.mangaScrollMode = value }
-
-    var mangaLongClick
-        get() = ReadMangaConfig.mangaLongClick
-        set(value) { ReadMangaConfig.mangaLongClick = value }
-
-    var mangaBackground: Int
-        get() = ReadMangaConfig.mangaBackground
-        set(value) { ReadMangaConfig.mangaBackground = value }
-
-    //漫画滤镜
-    var mangaColorFilter
-        get() = ReadMangaConfig.mangaColorFilter
-        set(value) { ReadMangaConfig.mangaColorFilter = value }
-
-    //禁用漫画内标题
-    var hideMangaTitle
-        get() = ReadMangaConfig.hideMangaTitle
-        set(value) { ReadMangaConfig.hideMangaTitle = value }
-
-    //开启墨水屏模式
-    var enableMangaEInk
-        get() = ReadMangaConfig.enableMangaEInk
-        set(value) { ReadMangaConfig.enableMangaEInk = value }
-
-    //墨水屏阈值
-    var mangaEInkThreshold
-        get() = ReadMangaConfig.mangaEInkThreshold
-        set(value) { ReadMangaConfig.mangaEInkThreshold = value }
-
-    //漫画灰度
-    var enableMangaGray
-        get() = ReadMangaConfig.enableMangaGray
-        set(value) { ReadMangaConfig.enableMangaGray = value }
-
-    //条漫侧边距
-    var webtoonSidePaddingDp: Int
-        get() = ReadMangaConfig.webtoonSidePaddingDp
-        set(value) { ReadMangaConfig.webtoonSidePaddingDp = value }
-
-    //漫画音量键翻页
-    var MangaVolumeKeyPage: Boolean
-        get() = ReadMangaConfig.mangaVolumeKeyPage
-        set(value) { ReadMangaConfig.mangaVolumeKeyPage = value }
-
-    var reverseVolumeKeyPage: Boolean
-        get() = ReadMangaConfig.reverseVolumeKeyPage
-        set(value) { ReadMangaConfig.reverseVolumeKeyPage = value }
-
-    var tabletInterface
-        get() = ThemeConfig.tabletInterface
-        set(value) {
-            ThemeConfig.tabletInterface = value
-        }
-
-    var pureBlack
-        get() = ThemeConfig.isPureBlack
-        set(value) {
-            ThemeConfig.isPureBlack = value
-        }
-
-    val hasLightBg: Boolean
-        get() = !ThemeConfig.bgImageLight.isNullOrEmpty()
-
-    val hasDarkBg: Boolean
-        get() = !ThemeConfig.bgImageDark.isNullOrEmpty()
-
-    val hasImageBg: Boolean
-        get() = hasLightBg && hasDarkBg
-
-    var labelVisibilityMode
-        get() = ThemeConfig.labelVisibilityMode
-        set(value) {
-            ThemeConfig.labelVisibilityMode = value
-        }
-
-    var paletteStyle
-        get() = ThemeConfig.paletteStyle
-        set(value) {
-            ThemeConfig.paletteStyle = value
-        }
-
-    var enableBlur
-        get() = ThemeConfig.enableBlur
-        set(value) {
-            ThemeConfig.enableBlur = value
-        }
-
-    @Deprecated("Use ReadBookConfig.readMenuBlurAlpha instead", ReplaceWith("ReadBookConfig.readMenuBlurAlpha"))
-    var menuAlpha: Int
-        get() = ReadBookConfig.readMenuBlurAlpha
-        set(value) {
-            ReadBookConfig.readMenuBlurAlpha = value
-        }
-
-    var readSliderMode
-        get() = ReadBookConfig.readSliderMode
-        set(value) {
-            ReadBookConfig.readSliderMode = value
-        }
-
-    var bookshelfRefreshingLimit: Int
-        get() = BookshelfConfig.bookshelfRefreshingLimit
-        set(value) {
-            BookshelfConfig.bookshelfRefreshingLimit = value
-        }
-
-    var systemMediaControlCompatibilityChange: Boolean
-        get() = ReadConfig.systemMediaControlCompatibilityChange
-        set(value) {
-            ReadConfig.systemMediaControlCompatibilityChange = value
-        }
-
-    var isPredictiveBackEnabled: Boolean
-        get() = ThemeConfig.isPredictiveBackEnabled
-        set(value) {
-            ThemeConfig.isPredictiveBackEnabled = value
-        }
-
-    var shouldShowExpandButton: Boolean
-        get() = BookshelfConfig.shouldShowExpandButton
-        set(value) {
-            BookshelfConfig.shouldShowExpandButton = value
-        }
-
-    var defaultSourceChangeAll: Boolean
-        get() = ReadConfig.defaultSourceChangeAll
-        set(value) {
-            ReadConfig.defaultSourceChangeAll = value
-        }
-
-    var sliderVibrator: Boolean
-        get() = ReadConfig.sliderVibrator
-        set(value) {
-            ReadConfig.sliderVibrator = value
-        }
-
-    var selectVibrator: Boolean
-        get() = ReadConfig.selectVibrator
-        set(value) {
-            ReadConfig.selectVibrator = value
-        }
-
-    val audioPreDownloadNum: Int
-        get() = appCtx.getPrefInt(PreferKey.audioPreDownloadNum, 10)
-
-    val audioCacheCleanTimeOrgin: Int
-        get() = appCtx.getPrefInt(PreferKey.audioCacheCleanTime, 10)
-
-    val audioCacheCleanTime: Long
-        get() {
-            val str = appCtx.getPrefInt(PreferKey.audioCacheCleanTime, 10)
-            return str * 60 * 1000L
-        }
-
-    var containerOpacity: Int
-        get() = ThemeConfig.containerOpacity
-        set(value) {
-            ThemeConfig.containerOpacity = value
-        }
+    val bookshelfLayoutModePortrait get() = bookshelf.bookshelfLayoutModePortrait
+    val bookshelfLayoutModeLandscape get() = bookshelf.bookshelfLayoutModeLandscape
+    val bookshelfLayoutGridPortrait get() = bookshelf.bookshelfLayoutGridPortrait
+    val bookshelfLayoutGridLandscape get() = bookshelf.bookshelfLayoutGridLandscape
+    val bookExportFileName get() = export.bookExportFileName
+    val episodeExportFileName get() = export.episodeExportFileName
+    val bookImportFileName get() = importBook.bookImportFileName
+    val backupPath get() = backup.backupPath
+    val showStatusBar get() = shell.showStatusBar
+    val autoRefreshBook get() = other.autoRefresh
+    val threadCount get() = cache.threadCount
+    val importBookPath get() = importBook.importBookPath
+    val chineseConverterType get() = read.chineseConverterType
+    val enableCustomExport get() = export.enableCustomExport
+    val exportType get() = export.exportType
+    val importKeepName get() = other.importKeepName
+    val importKeepGroup get() = other.importKeepGroup
+    val importKeepEnable get() = other.importKeepEnable
+    val preDownloadNum get() = cache.preDownloadNum
+    val syncBookProgress get() = backup.syncBookProgress
+    val mediaButtonOnExit get() = aloud.mediaButtonOnExit
+    val readAloudByMediaButton get() = aloud.readAloudByMediaButton
+    val replaceEnableDefault get() = other.replaceEnableDefault
+    val webDavDir get() = backup.webDavDir
+    val webDavDeviceName get() = backup.webDavDeviceName
+    val onlyLatestBackup get() = backup.onlyLatestBackup
+    val autoCheckNewBackup get() = backup.autoCheckNewBackup
+    val updateToVariant get() = other.updateToVariant
+    val bookshelfSort get() = bookshelf.bookshelfSort
+    val bitmapCacheSize get() = cache.bitmapCacheSize
+    val sourceEditMaxLine get() = other.sourceEditMaxLine
+    val audioPlayUseWakeLock get() = other.audioPlayUseWakeLock
+    val firebaseEnable get() = other.firebaseEnable
+    val pureBlack get() = theme.isPureBlack
+    val systemMediaControlCompatibilityChange get() = aloud.systemMediaControlCompatibilityChange
+    val isPredictiveBackEnabled get() = shell.predictiveBackEnabled
 }

+ 34 - 11
app/src/main/java/io/legado/app/help/config/AppConfigStore.kt

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.emptyPreferences
 import io.legado.app.data.repository.dataStore
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
@@ -101,6 +102,10 @@ object AppConfigStore {
     /** 批量写入(Restore 恢复备份等):整批立即对读侧生效,单次 edit 落盘 */
     fun putAll(values: Map<String, Any?>) = requireCore().putAll(values)
 
+    /** 批量写入并等待 DataStore edit 完成;失败会回滚 overlay 并向调用方抛出。 */
+    suspend fun putAllAndAwait(values: Map<String, Any?>) =
+        requireCore().putAllAndAwait(values)
+
     // 订阅:替代 SP 监听器的统一入口,值不存在时发 null
     fun observeString(key: String): Flow<String?> = observe { it.compatDsString(key) }
     fun observeInt(key: String): Flow<Int?> = observe { it.compatDsInt(key) }
@@ -163,26 +168,44 @@ internal class PendingOverlayCore(
         }
     }
 
-    fun putAll(values: Map<String, Any?>) {
+    fun putAll(values: Map<String, Any?>) = enqueueAll(values, completion = null)
+
+    suspend fun putAllAndAwait(values: Map<String, Any?>) {
         if (values.isEmpty()) return
+        val completion = CompletableDeferred<Result<Unit>>()
+        enqueueAll(values, completion)
+        completion.await().getOrThrow()
+    }
+
+    private fun enqueueAll(
+        values: Map<String, Any?>,
+        completion: CompletableDeferred<Result<Unit>>?,
+    ) {
+        if (values.isEmpty()) {
+            completion?.complete(Result.success(Unit))
+            return
+        }
         synchronized(lock) {
             val writes = values.mapValues { PendingWrite(it.value) }
             pending.putAll(writes)
             rebuild()
             launchWrite {
-                runCatching { persistAll(values) }.onFailure { e ->
-                    synchronized(lock) {
-                        var removed = false
-                        writes.forEach { (key, write) ->
-                            if (pending[key] === write) {
-                                pending.remove(key)
-                                removed = true
+                runCatching { persistAll(values) }
+                    .onSuccess { completion?.complete(Result.success(Unit)) }
+                    .onFailure { e ->
+                        synchronized(lock) {
+                            var removed = false
+                            writes.forEach { (key, write) ->
+                                if (pending[key] === write) {
+                                    pending.remove(key)
+                                    removed = true
+                                }
                             }
+                            if (removed) rebuild()
+                            LogUtils.e("AppConfigStore", "批量保存设置失败: keys=${values.keys}\n${e.stackTraceStr}")
                         }
-                        if (removed) rebuild()
-                        LogUtils.e("AppConfigStore", "批量保存设置失败: keys=${values.keys}\n${e.stackTraceStr}")
+                        completion?.complete(Result.failure(e))
                     }
-                }
             }
         }
     }

+ 128 - 192
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -2,33 +2,26 @@ package io.legado.app.help.config
 
 import android.content.Context
 import android.graphics.Color
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.ColorDrawable
 import android.graphics.drawable.Drawable
 import androidx.annotation.Keep
-import androidx.compose.runtime.State
 import androidx.core.graphics.toColorInt
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
-import io.legado.app.constant.PreferKey
-import io.legado.app.constant.ReadMenuBlurMode
-import io.legado.app.constant.ReadMenuBlurStyle
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.ReadStyleRepository
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ReadStyleGateway
+import io.legado.app.domain.gateway.ReadStyleMutation
+import io.legado.app.domain.gateway.ReadStyleStringKey
 import io.legado.app.help.DefaultData
 import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.ui.config.PrefDelegate
-import io.legado.app.ui.config.prefDelegate
+import io.legado.app.model.ReadSessionState
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getMeanColor
 import io.legado.app.utils.hexString
-import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 import java.io.InputStream
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
 
 /**
  * 阅读界面配置
@@ -36,45 +29,25 @@ import kotlin.reflect.KProperty
 @Suppress("ConstPropertyName")
 @Keep
 object ReadBookConfig {
-    var lastNavigationBarHeight: Int = 0
-
-    private val readStyleRepository: ReadStyleRepository
-        get() = GlobalContext.get().get()
-
-    // region prefDelegate helpers
-
-    /** 读写时自动 coerceIn 的 prefDelegate */
-    private fun <T : Comparable<T>> clampedPrefDelegate(
-        key: String,
-        defaultValue: T,
-        range: ClosedRange<T>,
-    ): PrefDelegate<T> {
-        val raw = prefDelegate(key, defaultValue)
-        return object : PrefDelegate<T> {
-            override val state: State<T> get() = raw.state
-            override fun dispose() = raw.dispose()
-            override fun getValue(thisRef: Any?, property: KProperty<*>): T =
-                raw.getValue(thisRef, property).coerceIn(range)
-            override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) =
-                raw.setValue(thisRef, property, value.coerceIn(range))
-        }
+    private lateinit var readStyleRepository: ReadStyleRepository
+    private lateinit var readStyleGateway: ReadStyleGateway
+    private lateinit var readSettingsGateway: ReadSettingsGateway
+    private val readSettings get() = readSettingsGateway.currentSettings
+
+    internal fun initialize(
+        readStyleRepository: ReadStyleRepository,
+        readSettingsGateway: ReadSettingsGateway,
+    ) {
+        this.readStyleRepository = readStyleRepository
+        this.readSettingsGateway = readSettingsGateway
+        initConfigs()
+        initShareConfig()
     }
 
-    /** Map<String,String> 存储为 JSON String 的 prefDelegate */
-    private class PrefDelegateMap(
-        key: String,
-        private val parse: (String?) -> Map<String, String>,
-        private val encode: (Map<String, String>) -> String,
-    ) : ReadWriteProperty<Any?, Map<String, String>> {
-        private val raw = prefDelegate<String?>(key, null)
-        override fun getValue(thisRef: Any?, property: KProperty<*>): Map<String, String> =
-            parse(raw.getValue(thisRef, property))
-        override fun setValue(thisRef: Any?, property: KProperty<*>, value: Map<String, String>) =
-            raw.setValue(thisRef, property, encode(value))
+    internal fun attachGateway(readStyleGateway: ReadStyleGateway) {
+        this.readStyleGateway = readStyleGateway
     }
 
-    // endregion
-
     // region Tip position constants
     const val tipNone = 0
     const val tipChapterTitle = 1
@@ -101,8 +74,32 @@ object ReadBookConfig {
     const val shareConfigFileName = "shareReadConfig.json"
     val configFilePath: String get() = readStyleRepository.configFilePath
     val shareConfigFilePath: String get() = readStyleRepository.shareConfigFilePath
-    val configList: ArrayList<Config> = arrayListOf()
-    lateinit var shareConfig: Config
+    private val configList: ArrayList<Config> = arrayListOf()
+    private lateinit var shareConfig: Config
+
+    internal fun configsSnapshot(): List<Config> = synchronized(this) {
+        configList.map { it.copy() }
+    }
+
+    internal fun shareConfigSnapshot(): Config = synchronized(this) { shareConfig.copy() }
+
+    internal fun addConfig(config: Config): Int = synchronized(this) {
+        configList.add(config)
+        configList.lastIndex
+    }
+
+    internal fun importOrReplaceConfig(config: Config): String = synchronized(this) {
+        val index = configList.indexOfFirst { it.name == config.name }
+        if (index >= 0) {
+            configList[index] = config
+        } else {
+            configList.add(config)
+        }
+        config.name
+    }
+
+    internal val configCount: Int get() = synchronized(this) { configList.size }
+    internal fun configNames(): List<String> = synchronized(this) { configList.map { it.name } }
     var durConfig
         get() = getConfig(styleSelect)
         set(value) {
@@ -112,9 +109,6 @@ object ReadBookConfig {
             }
         }
 
-    var isComic: Boolean = false
-    var bg: Drawable? = null
-    var bgMeanColor: Int = 0
     val textColor: Int get() = durConfig.curTextColor()
     val textColorNight: Int
         get() = try {
@@ -125,11 +119,6 @@ object ReadBookConfig {
     val textAccentColor: Int get() = durConfig.curTextAccentColor()
     val textShadowColor: Int get() = durConfig.curTextShadowColor()
     val menuColor: Int get() = readMenuAccentColor
-    init {
-        initConfigs()
-        initShareConfig()
-    }
-
     @Synchronized
     fun getConfig(index: Int): Config {
         if (configList.size < 5) {
@@ -149,16 +138,6 @@ object ReadBookConfig {
         shareConfig = readStyleRepository.readShareConfig(configList.getOrNull(5) ?: Config())
     }
 
-    fun upBg(width: Int, height: Int) {
-        val drawable = durConfig.curBgDrawable(width, height)
-        if (drawable is BitmapDrawable && drawable.bitmap != null) {
-            bgMeanColor = drawable.bitmap.getMeanColor()
-        } else if (drawable is ColorDrawable) {
-            bgMeanColor = drawable.color
-        }
-        bg = drawable
-    }
-
     fun save() {
         Coroutine.async {
             synchronized(this) {
@@ -167,23 +146,24 @@ object ReadBookConfig {
         }
     }
 
+    fun clearMissingTextFont() {
+        readStyleGateway.updateCurrentStyle(
+            ReadStyleMutation.StringValue(ReadStyleStringKey.TextFont, "")
+        )
+        readStyleGateway.save()
+    }
+
     fun getAllPicBgStr(): ArrayList<String> {
         return readStyleRepository.getAllPicBgStr(configList)
     }
 
-    fun deleteDur(): Boolean {
+    internal fun deleteDur(): Int? {
         if (configList.size > 5) {
             val removeIndex = styleSelect
             configList.removeAt(removeIndex)
-            if (removeIndex <= readStyleSelect) {
-                readStyleSelect -= 1
-            }
-            if (removeIndex <= comicStyleSelect) {
-                comicStyleSelect -= 1
-            }
-            return true
+            return removeIndex
         }
-        return false
+        return null
     }
 
     fun clearBgAndCache() {
@@ -198,107 +178,70 @@ object ReadBookConfig {
         }
     }
 
-    // region Scalar properties (prefDelegate — auto-syncs SP + DataStore)
-
-    var readBodyToLh by prefDelegate(PreferKey.readBodyToLh, true)
-    var autoReadSpeed by prefDelegate(PreferKey.autoReadSpeed, 10)
-    var readStyleSelect by prefDelegate(PreferKey.readStyleSelect, 0)
-    var comicStyleSelect by prefDelegate(PreferKey.comicStyleSelect, 0)
-    var shareLayout by prefDelegate(PreferKey.shareLayout, false)
-    var textFullJustify by prefDelegate(PreferKey.textFullJustify, true)
-    var textBottomJustify by prefDelegate(PreferKey.textBottomJustify, true)
-    var hideStatusBar by prefDelegate(PreferKey.hideStatusBar, false)
-    var hideNavigationBar by prefDelegate(PreferKey.hideNavigationBar, false)
-    var useZhLayout by prefDelegate(PreferKey.useZhLayout, false)
-    var readMenuIconShowText by prefDelegate(PreferKey.readMenuIconShowText, true)
-    var showMenuIcon by prefDelegate(PreferKey.showMenuIcon, true)
-    var readMenuFloatingBottomBar by prefDelegate(PreferKey.readMenuFloatingBottomBar, false)
-    var readMenuTopBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuTopBarLiquidGlassButtons, false)
-    var readMenuTopBarTitleCapsule by prefDelegate(PreferKey.readMenuTopBarTitleCapsule, false)
-    var readMenuBottomBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuBottomBarLiquidGlassButtons, false)
-    var readMenuBorderColor by prefDelegate(PreferKey.readMenuBorderColor, 0)
-    var readMenuBorderColorNight by prefDelegate(PreferKey.readMenuBorderColorNight, 0)
-    var readMenuTextColor by prefDelegate(PreferKey.readMenuTextColor, 0)
-    var readMenuTextColorNight by prefDelegate(PreferKey.readMenuTextColorNight, 0)
-    var showTitleBarIcons by prefDelegate(PreferKey.showTitleBarIcons, true)
-    var readSliderMode by prefDelegate(PreferKey.readSliderMode, "0")
-    var showBrightnessView by prefDelegate(PreferKey.showBrightnessView, "1")
-    var brightnessVwPos by prefDelegate(PreferKey.brightnessVwPos, "1")
-    var readBrightness by prefDelegate(PreferKey.brightness, 100)
-    var brightnessAuto by prefDelegate(PreferKey.brightnessAuto, false)
-
-    var styleSelect: Int
-        get() = if (isComic) comicStyleSelect else readStyleSelect
-        set(value) {
-            if (isComic) comicStyleSelect = value else readStyleSelect = value
-        }
-
-    // endregion
-
-    // region coerceIn properties (clampedPrefDelegate)
-
-    var readMenuColorMode by clampedPrefDelegate(PreferKey.readMenuColorMode, 1, 0..1)
-    var readMenuIconStyle by clampedPrefDelegate(PreferKey.readMenuIconStyle, 0, 0..2)
-    var titleBarIconStyle by clampedPrefDelegate(PreferKey.titleBarIconStyle, 0, 0..2)
-    var readMenuIconItemsPerRow by clampedPrefDelegate(PreferKey.readMenuIconItemsPerRow, 5, 2..8)
-    var readMenuIconRowCount by clampedPrefDelegate(PreferKey.readMenuIconRowCount, 1, 1..2)
-    var readMenuBottomCornerRadius by clampedPrefDelegate(PreferKey.readMenuBottomCornerRadius, 0, 0..32)
-    var readMenuTopBarBlurMode by clampedPrefDelegate(PreferKey.readMenuTopBarBlurMode, ReadMenuBlurMode.None, 0..2)
-    var readMenuBottomBarBlurMode by clampedPrefDelegate(PreferKey.readMenuBottomBarBlurMode, ReadMenuBlurMode.None, 0..2)
-    var readMenuTopBarBlurStyle by clampedPrefDelegate(PreferKey.readMenuTopBarBlurStyle, ReadMenuBlurStyle.Progressive, 0..1)
-    var readMenuBottomBarBlurStyle by clampedPrefDelegate(PreferKey.readMenuBottomBarBlurStyle, ReadMenuBlurStyle.Solid, 0..1)
-    var readMenuBlurRadius by clampedPrefDelegate(PreferKey.readMenuBlurRadius, 24, 0..32)
-    var readMenuBlurAlpha by clampedPrefDelegate(PreferKey.readMenuBlurAlpha, 60, 0..100)
-    var readMenuBlurColor by prefDelegate(PreferKey.readMenuBlurColor, 0)
-    var readMenuBlurColorNight by prefDelegate(PreferKey.readMenuBlurColorNight, 0)
-    var readMenuPaletteStyle: String = ""
-    var readMenuLensRadius by clampedPrefDelegate(PreferKey.readMenuLensRadius, 24f, 0f..48f)
-    var readMenuBorderWidth by clampedPrefDelegate(PreferKey.readMenuBorderWidth, 0, 0..4)
-    var titleBarIconPosition by clampedPrefDelegate(PreferKey.titleBarIconPosition, 0, 0..3)
-
-    // endregion
-
-    // region Color properties (prefDelegate backing + custom fallback getter)
-
-    private val readMenuBgColorDelegate = prefDelegate(PreferKey.readMenuBgColor, 0)
-    var readMenuBgColor: Int
-        get() = readMenuBgColorDelegate.getValue(this, ::readMenuBgColor)
-            .takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = false)
-        set(value) = readMenuBgColorDelegate.setValue(this, ::readMenuBgColor, value)
-
-    private val readMenuAccentColorDelegate = prefDelegate(PreferKey.readMenuAccentColor, 0)
-    var readMenuAccentColor: Int
-        get() = readMenuAccentColorDelegate.getValue(this, ::readMenuAccentColor)
-            .takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = false)
-        set(value) = readMenuAccentColorDelegate.setValue(this, ::readMenuAccentColor, value)
-
-    private val readMenuContainerColorDelegate = prefDelegate(PreferKey.readMenuContainerColor, 0)
-    var readMenuContainerColor: Int
-        get() = readMenuContainerColorDelegate.getValue(this, ::readMenuContainerColor)
-            .takeIf { it != 0 } ?: readMenuBgColor
-        set(value) = readMenuContainerColorDelegate.setValue(this, ::readMenuContainerColor, value)
-
-    private val readMenuBgColorNightDelegate = prefDelegate(PreferKey.readMenuBgColorNight, 0)
-    var readMenuBgColorNight: Int
-        get() = readMenuBgColorNightDelegate.getValue(this, ::readMenuBgColorNight)
-            .takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = true)
-        set(value) = readMenuBgColorNightDelegate.setValue(this, ::readMenuBgColorNight, value)
-
-    private val readMenuAccentColorNightDelegate = prefDelegate(PreferKey.readMenuAccentColorNight, 0)
-    var readMenuAccentColorNight: Int
-        get() = readMenuAccentColorNightDelegate.getValue(this, ::readMenuAccentColorNight)
-            .takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = true)
-        set(value) = readMenuAccentColorNightDelegate.setValue(this, ::readMenuAccentColorNight, value)
-
-    private val readMenuContainerColorNightDelegate = prefDelegate(PreferKey.readMenuContainerColorNight, 0)
-    var readMenuContainerColorNight: Int
-        get() = readMenuContainerColorNightDelegate.getValue(this, ::readMenuContainerColorNight)
-            .takeIf { it != 0 } ?: readMenuBgColorNight
-        set(value) = readMenuContainerColorNightDelegate.setValue(this, ::readMenuContainerColorNight, value)
-
-    // endregion
-
-    // region Map properties (PrefDelegateMap — JSON string serialization)
+    // DataStore 标量已归入 ReadSettings;这里仅保留旧渲染层所需的同步只读快照。
+    val readBodyToLh get() = readSettings.readBodyToLh
+    val autoReadSpeed get() = readSettings.autoReadSpeed
+    val readStyleSelect get() = readSettings.readStyleSelect
+    val comicStyleSelect get() = readSettings.comicStyleSelect
+    val shareLayout get() = readSettings.shareLayout
+    val textFullJustify get() = readSettings.textFullJustify
+    val textBottomJustify get() = readSettings.textBottomJustify
+    val hideStatusBar get() = readSettings.hideStatusBar
+    val hideNavigationBar get() = readSettings.hideNavigationBar
+    val useZhLayout get() = readSettings.useZhLayout
+    val readMenuIconShowText get() = readSettings.readMenuIconShowText
+    val showMenuIcon get() = readSettings.showMenuIcon
+    val readMenuFloatingBottomBar get() = readSettings.readMenuFloatingBottomBar
+    val readMenuTopBarLiquidGlassButtons get() = readSettings.readMenuTopBarLiquidGlassButtons
+    val readMenuTopBarTitleCapsule get() = readSettings.readMenuTopBarTitleCapsule
+    val readMenuBottomBarLiquidGlassButtons get() = readSettings.readMenuBottomBarLiquidGlassButtons
+    val readMenuBorderColor get() = readSettings.readMenuBorderColor
+    val readMenuBorderColorNight get() = readSettings.readMenuBorderColorNight
+    val readMenuTextColor get() = readSettings.readMenuTextColor
+    val readMenuTextColorNight get() = readSettings.readMenuTextColorNight
+    val showTitleBarIcons get() = readSettings.showTitleBarIcons
+    val readSliderMode get() = readSettings.readSliderMode
+    val showBrightnessView get() = readSettings.showBrightnessView
+    val brightnessVwPos get() = readSettings.brightnessVwPos
+    val readBrightness get() = readSettings.readBrightness
+    val brightnessAuto get() = readSettings.brightnessAuto
+    val styleSelect get() = if (ReadSessionState.isComic) comicStyleSelect else readStyleSelect
+    val readMenuColorMode get() = readSettings.readMenuColorMode.coerceIn(0, 1)
+    val readMenuIconStyle get() = readSettings.readMenuIconStyle.coerceIn(0, 2)
+    val titleBarIconStyle get() = readSettings.titleBarIconStyle.coerceIn(0, 2)
+    val readMenuIconItemsPerRow get() = readSettings.readMenuIconItemsPerRow.coerceIn(2, 8)
+    val readMenuIconRowCount get() = readSettings.readMenuIconRowCount.coerceIn(1, 2)
+    val readMenuBottomCornerRadius get() = readSettings.readMenuBottomCornerRadius.coerceIn(0, 32)
+    val readMenuTopBarBlurMode get() = readSettings.readMenuTopBarBlurMode.coerceIn(0, 2)
+    val readMenuBottomBarBlurMode get() = readSettings.readMenuBottomBarBlurMode.coerceIn(0, 2)
+    val readMenuTopBarBlurStyle get() = readSettings.readMenuTopBarBlurStyle.coerceIn(0, 1)
+    val readMenuBottomBarBlurStyle get() = readSettings.readMenuBottomBarBlurStyle.coerceIn(0, 1)
+    val readMenuBlurRadius get() = readSettings.readMenuBlurRadius.coerceIn(0, 32)
+    val readMenuBlurAlpha get() = readSettings.readMenuBlurAlpha.coerceIn(0, 100)
+    val readMenuBlurColor get() = readSettings.readMenuBlurColor
+    val readMenuBlurColorNight get() = readSettings.readMenuBlurColorNight
+    val readMenuPaletteStyle get() = readSettings.readMenuPaletteStyle
+    val readMenuLensRadius get() = readSettings.readMenuLensRadius.coerceIn(0f, 48f)
+    val readMenuBorderWidth get() = readSettings.readMenuBorderWidth.coerceIn(0, 4)
+    val titleBarIconPosition get() = readSettings.titleBarIconPosition.coerceIn(0, 3)
+    val readMenuBgColor: Int
+        get() = readSettings.readMenuBgColor.takeIf { it != 0 }
+            ?: durConfig.menuBgColor(isNight = false)
+    val readMenuAccentColor: Int
+        get() = readSettings.readMenuAccentColor.takeIf { it != 0 }
+            ?: durConfig.menuAccentColor(isNight = false)
+    val readMenuContainerColor: Int
+        get() = readSettings.readMenuContainerColor.takeIf { it != 0 } ?: readMenuBgColor
+    val readMenuBgColorNight: Int
+        get() = readSettings.readMenuBgColorNight.takeIf { it != 0 }
+            ?: durConfig.menuBgColor(isNight = true)
+    val readMenuAccentColorNight: Int
+        get() = readSettings.readMenuAccentColorNight.takeIf { it != 0 }
+            ?: durConfig.menuAccentColor(isNight = true)
+    val readMenuContainerColorNight: Int
+        get() = readSettings.readMenuContainerColorNight.takeIf { it != 0 } ?: readMenuBgColorNight
+
+    // region Map properties (JSON string serialization)
 
     fun encodeReadMenuCustomIcons(value: Map<String, String>): String {
         return GSON.toJson(value.filterValues { it.isNotBlank() })
@@ -310,13 +253,11 @@ object ReadBookConfig {
             ?.filterValues { it.isNotBlank() } ?: emptyMap()
     }
 
-    var readMenuCustomIcons: Map<String, String> by PrefDelegateMap(
-        PreferKey.readMenuCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
-    )
+    val readMenuCustomIcons: Map<String, String>
+        get() = parseReadMenuCustomIcons(readSettings.readMenuCustomIcons)
 
-    var titleBarCustomIcons: Map<String, String> by PrefDelegateMap(
-        PreferKey.titleBarCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
-    )
+    val titleBarCustomIcons: Map<String, String>
+        get() = parseReadMenuCustomIcons(readSettings.titleBarCustomIcons)
 
     // endregion
 
@@ -333,7 +274,8 @@ object ReadBookConfig {
                             if (isNight) Color.BLACK else Color.WHITE
                         }
                     } else {
-                        bgMeanColor.takeIf { it != 0 } ?: (if (isNight) Color.BLACK else Color.WHITE)
+                        ReadSessionState.backgroundMeanColor.takeIf { it != 0 }
+                            ?: (if (isNight) Color.BLACK else Color.WHITE)
                     }
                 }
                 2 -> { // 自定义
@@ -697,17 +639,11 @@ object ReadBookConfig {
             config.setUnderlineColor(value)
         }
 
-    var menuBgColor: Int
+    val menuBgColor: Int
         get() = readMenuBgColor
-        set(value) {
-            readMenuBgColor = value
-        }
 
-    var menuAcColor: Int
+    val menuAcColor: Int
         get() = readMenuAccentColor
-        set(value) {
-            readMenuAccentColor = value
-        }
 
     var shadowColor: Int
         get() = config.curTextShadowColor()

+ 60 - 11
app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt

@@ -5,6 +5,7 @@ import android.graphics.drawable.Drawable
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.toColorInt
 import com.google.android.material.color.MaterialColors
+import io.legado.app.model.ReadSessionState
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.BitmapUtils
 import io.legado.app.utils.FileUtils
@@ -28,15 +29,16 @@ object ReadStyleResolver {
     )
 
     fun currentMode(): ReadStyleMode {
-        return when {
-            ReadConfig.isEInkMode -> ReadStyleMode.EInk
-            ReadConfig.isNightTheme -> ReadStyleMode.Night
-            else -> ReadStyleMode.Day
-        }
+        val isNightTheme = ReadSessionState.isDarkThemeOverride ?: ReadConfig.isNightTheme
+        return resolveReadStyleMode(ReadConfig.isEInkMode, isNightTheme)
+    }
+
+    fun currentMode(isNightTheme: Boolean): ReadStyleMode {
+        return resolveReadStyleMode(ReadConfig.isEInkMode, isNightTheme)
     }
 
     fun isNightTheme(): Boolean {
-        return ReadConfig.isNightTheme
+        return ReadSessionState.isDarkThemeOverride ?: ReadConfig.isNightTheme
     }
 
     fun setCurrentBackground(
@@ -63,7 +65,21 @@ object ReadStyleResolver {
     }
 
     fun currentBackground(config: ReadBookConfig.Config): ReadBackground {
-        return when (currentMode()) {
+        return currentBackground(config, currentMode())
+    }
+
+    fun currentBackground(
+        config: ReadBookConfig.Config,
+        isNightTheme: Boolean
+    ): ReadBackground {
+        return currentBackground(config, currentMode(isNightTheme))
+    }
+
+    private fun currentBackground(
+        config: ReadBookConfig.Config,
+        mode: ReadStyleMode
+    ): ReadBackground {
+        return when (mode) {
             ReadStyleMode.EInk -> ReadBackground(config.bgTypeEInk, config.bgStrEInk)
             ReadStyleMode.Night -> ReadBackground(config.bgTypeNight, config.bgStrNight)
             ReadStyleMode.Day -> ReadBackground(config.bgType, config.bgStr)
@@ -77,15 +93,19 @@ object ReadStyleResolver {
             2 -> config.bgTypeEInk
             else -> error("unknown bgIndex: $bgIndex")
         }
-        if (bgType != 2) {
-            return null
-        }
         val bgStr = when (bgIndex) {
             0 -> config.bgStr
             1 -> config.bgStrNight
             2 -> config.bgStrEInk
             else -> error("unknown bgIndex: $bgIndex")
         }
+        return backgroundPath(bgType, bgStr)
+    }
+
+    fun backgroundPath(bgType: Int, bgStr: String): String? {
+        if (bgType != 2) {
+            return null
+        }
         return if (bgStr.contains(File.separator)) {
             bgStr
         } else {
@@ -97,6 +117,24 @@ object ReadStyleResolver {
         config: ReadBookConfig.Config,
         width: Int,
         height: Int
+    ): Drawable {
+        return currentBackgroundDrawable(config, width, height, currentMode())
+    }
+
+    fun currentBackgroundDrawable(
+        config: ReadBookConfig.Config,
+        width: Int,
+        height: Int,
+        isNightTheme: Boolean
+    ): Drawable {
+        return currentBackgroundDrawable(config, width, height, currentMode(isNightTheme))
+    }
+
+    private fun currentBackgroundDrawable(
+        config: ReadBookConfig.Config,
+        width: Int,
+        height: Int,
+        mode: ReadStyleMode
     ): Drawable {
         if (width == 0 || height == 0) {
             return fallbackBackground()
@@ -104,7 +142,7 @@ object ReadStyleResolver {
 
         var bgDrawable: Drawable? = null
         val resources = appCtx.resources
-        val background = currentBackground(config)
+        val background = currentBackground(config, mode)
         try {
             bgDrawable = when (background.type) {
                 0 -> background.value.toColorInt().toDrawable()
@@ -140,3 +178,14 @@ object ReadStyleResolver {
         return fallbackColor.toDrawable()
     }
 }
+
+internal fun resolveReadStyleMode(
+    isEInkMode: Boolean,
+    isNightTheme: Boolean
+): ReadStyleResolver.ReadStyleMode {
+    return when {
+        isEInkMode -> ReadStyleResolver.ReadStyleMode.EInk
+        isNightTheme -> ReadStyleResolver.ReadStyleMode.Night
+        else -> ReadStyleResolver.ReadStyleMode.Day
+    }
+}

+ 61 - 462
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -1,20 +1,16 @@
 package io.legado.app.help.config
 
-import android.content.Context
-import android.net.Uri
 import androidx.annotation.Keep
-import com.google.gson.GsonBuilder
 import com.google.gson.JsonObject
 import com.google.gson.JsonParser
 import com.google.gson.reflect.TypeToken
-import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
-import io.legado.app.ui.config.coverConfig.CoverConfig
-import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.domain.model.settings.ThemeExportData
 import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.GSON
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileOutputStream
+import java.util.UUID
 
 /**
  * 轻量级主题导入导出系统
@@ -23,148 +19,23 @@ import java.io.FileOutputStream
  */
 object ThemeImportExport {
 
-    private val EXPORT_GSON = GsonBuilder()
-        .setPrettyPrinting()
-        .disableHtmlEscaping()
-        .create()
-
-    /**
-     * 从当前配置创建导出数据
-     */
-    fun exportFromCurrent(includeEmbeddedAssets: Boolean = true): ThemeExportData {
-        return ThemeExportData(
-            // 基础主题设置
-            appTheme = ThemeConfig.appTheme,
-            themeMode = ThemeConfig.themeMode,
-            isPureBlack = ThemeConfig.isPureBlack,
-            composeEngine = ThemeConfig.composeEngine,
-            paletteStyle = ThemeConfig.paletteStyle,
-            materialVersion = ThemeConfig.materialVersion,
-            customMode = ThemeConfig.customMode,
-            customContrast = ThemeConfig.customContrast,
-            launcherIcon = ThemeConfig.launcherIcon,
-            isPredictiveBackEnabled = ThemeConfig.isPredictiveBackEnabled,
-            fontScale = ThemeConfig.fontScale,
-
-            // 深度个性化颜色
-            enableDeepPersonalization = ThemeConfig.enableDeepPersonalization,
-            cPrimary = ThemeConfig.cPrimary,
-            cNPrimary = ThemeConfig.cNPrimary,
-            themeColor = ThemeConfig.themeColor,
-            secondaryThemeColor = ThemeConfig.secondaryThemeColor,
-            primaryTextColor = ThemeConfig.primaryTextColor,
-            secondaryTextColor = ThemeConfig.secondaryTextColor,
-            themeBackgroundColor = ThemeConfig.themeBackgroundColor,
-            labelContainerColor = ThemeConfig.labelContainerColor,
-            themeColorNight = ThemeConfig.themeColorNight,
-            secondaryThemeColorNight = ThemeConfig.secondaryThemeColorNight,
-            primaryTextColorNight = ThemeConfig.primaryTextColorNight,
-            secondaryTextColorNight = ThemeConfig.secondaryTextColorNight,
-            themeBackgroundColorNight = ThemeConfig.themeBackgroundColorNight,
-            labelContainerColorNight = ThemeConfig.labelContainerColorNight,
-            bookInfoInputColor = ThemeConfig.bookInfoInputColor,
-            bookInfoFollowCoverColor = ThemeConfig.bookInfoFollowCoverColor,
-            bookInfoBackgroundBlur = ThemeConfig.bookInfoBackgroundBlur,
-            bookInfoNetworkCoverBackground = ThemeConfig.bookInfoNetworkCoverBackground,
-            bookInfoDefaultCoverBackground = ThemeConfig.bookInfoDefaultCoverBackground,
-
-            // 容器设置
-            containerOpacity = ThemeConfig.containerOpacity,
-
-            // 分割线设置
-            enableItemDivider = ThemeConfig.enableItemDivider,
-            itemDividerWidth = ThemeConfig.itemDividerWidth,
-            itemDividerLength = ThemeConfig.itemDividerLength,
-            itemDividerColor = ThemeConfig.itemDividerColor,
-
-            // 模糊设置
-            enableBlur = ThemeConfig.enableBlur,
-            enableProgressiveBlur = ThemeConfig.enableProgressiveBlur,
-            topBarBlurRadius = ThemeConfig.topBarBlurRadius,
-            bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius,
-            topBarBlurAlpha = ThemeConfig.topBarBlurAlpha,
-            bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha,
-            bottomBarLensRadius = ThemeConfig.bottomBarLensRadius,
-
-            // 透明度设置
-            topBarOpacity = ThemeConfig.topBarOpacity,
-            bottomBarOpacity = ThemeConfig.bottomBarOpacity,
-
-            // 标签颜色
-            enableCustomTagColors = ThemeConfig.enableCustomTagColors,
-            customTagColorsJson = ThemeConfig.customTagColorsJson,
-
-            // 书架卡片颜色
-            bookshelfCardColor = BookshelfConfig.bookshelfCardColor,
-            bookshelfCardColorDark = BookshelfConfig.bookshelfCardColorDark,
-
-            // 主界面设置
-            showHome = ThemeConfig.showHome,
-            showDiscovery = ThemeConfig.showDiscovery,
-            showRss = ThemeConfig.showRss,
-            showStatusBar = ThemeConfig.showStatusBar,
-            swipeAnimation = ThemeConfig.swipeAnimation,
-            showBottomView = ThemeConfig.showBottomView,
-            useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
-            useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
-            tabletInterface = ThemeConfig.tabletInterface,
-            labelVisibilityMode = ThemeConfig.labelVisibilityMode,
-            defaultHomePage = ThemeConfig.defaultHomePage,
-            mainNavigationOrder = ThemeConfig.mainNavigationOrder,
-
-            // 导航栏图标
-            navIconHome = ThemeConfig.navIconHome,
-            navIconBookshelf = ThemeConfig.navIconBookshelf,
-            navIconExplore = ThemeConfig.navIconExplore,
-            navIconRss = ThemeConfig.navIconRss,
-            navIconMy = ThemeConfig.navIconMy,
-
-            // Miuix 设置
-            useMiuixMonet = ThemeConfig.useMiuixMonet,
-
-            // 其他
-            useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar,
-            bgImageLight = ThemeConfig.bgImageLight,
-            bgImageDark = ThemeConfig.bgImageDark,
-            bgImageBlurring = ThemeConfig.bgImageBlurring,
-            bgImageNBlurring = ThemeConfig.bgImageNBlurring,
-            appFontPath = ThemeConfig.appFontPath,
-
-            // 封面配置 (CoverConfig)
-            coverLoadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
-            coverUseDefault = CoverConfig.useDefaultCover,
-            coverShowShadow = CoverConfig.coverShowShadow,
-            coverShowStroke = CoverConfig.coverShowStroke,
-            coverDefaultColor = CoverConfig.coverDefaultColor,
-            coverDefaultImage = CoverConfig.defaultCover,
-            coverTextColor = CoverConfig.coverTextColor,
-            coverShadowColor = CoverConfig.coverShadowColor,
-            coverShowName = CoverConfig.coverShowName,
-            coverShowAuthor = CoverConfig.coverShowAuthor,
-            coverDefaultImageDark = CoverConfig.defaultCoverDark,
-            coverTextColorN = CoverConfig.coverTextColorN,
-            coverShadowColorN = CoverConfig.coverShadowColorN,
-            coverShowNameN = CoverConfig.coverShowNameN,
-            coverShowAuthorN = CoverConfig.coverShowAuthorN,
-            coverInfoOrientation = CoverConfig.coverInfoOrientation,
-            assets = if (includeEmbeddedAssets) exportAssets() else null
-        )
-    }
+    internal fun withEmbeddedAssets(data: ThemeExportData): ThemeExportData =
+        data.copy(assets = exportAssets(data))
 
     /**
      * 将相关的图片、字体资源转换为Base64
      */
-    private fun exportAssets(): Map<String, String> {
+    private fun exportAssets(data: ThemeExportData): Map<String, String> {
         val assets = mutableMapOf<String, String>()
         val assetPaths = mapOf(
-            "bgImageLight" to ThemeConfig.bgImageLight,
-            "bgImageDark" to ThemeConfig.bgImageDark,
-            "navIconHome" to ThemeConfig.navIconHome,
-            "navIconBookshelf" to ThemeConfig.navIconBookshelf,
-            "navIconExplore" to ThemeConfig.navIconExplore,
-            "navIconRss" to ThemeConfig.navIconRss,
-            "navIconMy" to ThemeConfig.navIconMy,
-            "appFontPath" to ThemeConfig.appFontPath
+            "bgImageLight" to data.bgImageLight,
+            "bgImageDark" to data.bgImageDark,
+            "navIconHome" to data.navIconHome,
+            "navIconBookshelf" to data.navIconBookshelf,
+            "navIconExplore" to data.navIconExplore,
+            "navIconRss" to data.navIconRss,
+            "navIconMy" to data.navIconMy,
+            "appFontPath" to data.appFontPath,
         )
 
         assetPaths.forEach { (key, path) ->
@@ -185,8 +56,8 @@ object ThemeImportExport {
         }
 
         // 处理封面图集(可能包含多个逗号分隔的路径)
-        exportCoverAssets(assets, "coverDefaultImage", CoverConfig.defaultCover)
-        exportCoverAssets(assets, "coverDefaultImageDark", CoverConfig.defaultCoverDark)
+        exportCoverAssets(assets, "coverDefaultImage", data.coverDefaultImage)
+        exportCoverAssets(assets, "coverDefaultImageDark", data.coverDefaultImageDark)
 
         return assets
     }
@@ -214,144 +85,42 @@ object ThemeImportExport {
         }
     }
 
-    /**
-     * 将导出数据应用到当前配置
-     */
-    internal fun applyToThemeConfig(
+    internal fun prepareForApply(
         data: ThemeExportData,
         applyEmbeddedCoverAssets: Boolean = true,
-    ): AppliedThemeAssets {
-        // 基础主题设置
-        ThemeConfig.appTheme = data.appTheme
-        ThemeConfig.themeMode = data.themeMode
-        ThemeConfig.isPureBlack = data.isPureBlack
-        ThemeConfig.composeEngine = data.composeEngine
-        ThemeConfig.paletteStyle = data.paletteStyle
-        ThemeConfig.materialVersion = data.materialVersion
-        ThemeConfig.customMode = data.customMode
-        ThemeConfig.customContrast = data.customContrast
-        ThemeConfig.launcherIcon = data.launcherIcon
-        ThemeConfig.isPredictiveBackEnabled = data.isPredictiveBackEnabled
-        ThemeConfig.fontScale = data.fontScale
-
-        // 深度个性化颜色
-        ThemeConfig.enableDeepPersonalization = data.enableDeepPersonalization
-        ThemeConfig.cPrimary = data.cPrimary
-        ThemeConfig.cNPrimary = data.cNPrimary
-        ThemeConfig.themeColor = data.themeColor
-        ThemeConfig.secondaryThemeColor = data.secondaryThemeColor
-        ThemeConfig.primaryTextColor = data.primaryTextColor
-        ThemeConfig.secondaryTextColor = data.secondaryTextColor
-        ThemeConfig.themeBackgroundColor = data.themeBackgroundColor
-        ThemeConfig.labelContainerColor = data.labelContainerColor
-        ThemeConfig.themeColorNight = data.themeColorNight
-        ThemeConfig.secondaryThemeColorNight = data.secondaryThemeColorNight
-        ThemeConfig.primaryTextColorNight = data.primaryTextColorNight
-        ThemeConfig.secondaryTextColorNight = data.secondaryTextColorNight
-        ThemeConfig.themeBackgroundColorNight = data.themeBackgroundColorNight
-        ThemeConfig.labelContainerColorNight = data.labelContainerColorNight
-        ThemeConfig.bookInfoInputColor = data.bookInfoInputColor
-        ThemeConfig.bookInfoFollowCoverColor = data.bookInfoFollowCoverColor
-        ThemeConfig.bookInfoBackgroundBlur = data.bookInfoBackgroundBlur
-        ThemeConfig.bookInfoNetworkCoverBackground =
-            data.bookInfoNetworkCoverBackground ?: data.bookInfoBackgroundBlur
-        ThemeConfig.bookInfoDefaultCoverBackground =
-            data.bookInfoDefaultCoverBackground ?: data.bookInfoBackgroundBlur
-
-        // 容器设置
-        ThemeConfig.containerOpacity = data.containerOpacity
-
-        // 分割线设置
-        ThemeConfig.enableItemDivider = data.enableItemDivider
-        ThemeConfig.itemDividerWidth = data.itemDividerWidth
-        ThemeConfig.itemDividerLength = data.itemDividerLength
-        ThemeConfig.itemDividerColor = data.itemDividerColor
-
-        // 模糊设置
-        ThemeConfig.enableBlur = data.enableBlur
-        ThemeConfig.enableProgressiveBlur = data.enableProgressiveBlur
-        ThemeConfig.topBarBlurRadius = data.topBarBlurRadius
-        ThemeConfig.bottomBarBlurRadius = data.bottomBarBlurRadius
-        ThemeConfig.topBarBlurAlpha = data.topBarBlurAlpha
-        ThemeConfig.bottomBarBlurAlpha = data.bottomBarBlurAlpha
-        ThemeConfig.bottomBarLensRadius = data.bottomBarLensRadius
-
-        // 透明度设置
-        ThemeConfig.topBarOpacity = data.topBarOpacity
-        ThemeConfig.bottomBarOpacity = data.bottomBarOpacity
-
-        // 标签颜色
-        ThemeConfig.enableCustomTagColors = data.enableCustomTagColors
-        ThemeConfig.customTagColorsJson = data.customTagColorsJson
-
-        // 书架卡片颜色
-        BookshelfConfig.bookshelfCardColor = data.bookshelfCardColor
-        BookshelfConfig.bookshelfCardColorDark = data.bookshelfCardColorDark
-
-        // 主界面设置
-        ThemeConfig.showHome = data.showHome
-        ThemeConfig.showDiscovery = data.showDiscovery
-        ThemeConfig.showRss = data.showRss
-        ThemeConfig.showStatusBar = data.showStatusBar
-        ThemeConfig.swipeAnimation = data.swipeAnimation
-        ThemeConfig.showBottomView = data.showBottomView
-        ThemeConfig.useFloatingBottomBar = data.useFloatingBottomBar
-        ThemeConfig.useFloatingBottomBarLiquidGlass = data.useFloatingBottomBarLiquidGlass
-        ThemeConfig.tabletInterface = data.tabletInterface
-        ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
-        ThemeConfig.defaultHomePage = data.defaultHomePage
-        ThemeConfig.mainNavigationOrder = data.mainNavigationOrder
-
-        // 导航栏图标
-        ThemeConfig.navIconHome = data.navIconHome
-        ThemeConfig.navIconBookshelf = data.navIconBookshelf
-        ThemeConfig.navIconExplore = data.navIconExplore
-        ThemeConfig.navIconRss = data.navIconRss
-        ThemeConfig.navIconMy = data.navIconMy
-
-        // Miuix 设置
-        ThemeConfig.useMiuixMonet = data.useMiuixMonet
-
-        // 其他
-        ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar
-        ThemeConfig.bgImageLight = data.bgImageLight
-        ThemeConfig.bgImageDark = data.bgImageDark
-        ThemeConfig.bgImageBlurring = data.bgImageBlurring
-        ThemeConfig.bgImageNBlurring = data.bgImageNBlurring
-        ThemeConfig.appFontPath = data.appFontPath
-
-        // 封面配置 (CoverConfig)
-        CoverConfig.loadCoverOnlyWifi = data.coverLoadOnlyWifi
-        CoverConfig.useDefaultCover = data.coverUseDefault
-        CoverConfig.coverShowShadow = data.coverShowShadow
-        CoverConfig.coverShowStroke = data.coverShowStroke
-        CoverConfig.coverDefaultColor = data.coverDefaultColor
-        CoverConfig.defaultCover = data.coverDefaultImage
-        CoverConfig.coverTextColor = data.coverTextColor
-        CoverConfig.coverShadowColor = data.coverShadowColor
-        CoverConfig.coverShowName = data.coverShowName
-        CoverConfig.coverShowAuthor = data.coverShowAuthor
-        CoverConfig.defaultCoverDark = data.coverDefaultImageDark
-        CoverConfig.coverTextColorN = data.coverTextColorN
-        CoverConfig.coverShadowColorN = data.coverShadowColorN
-        CoverConfig.coverShowNameN = data.coverShowNameN
-        CoverConfig.coverShowAuthorN = data.coverShowAuthorN
-        CoverConfig.coverInfoOrientation = data.coverInfoOrientation
-
-        // 应用嵌入的资源
+    ): PreparedTheme {
         val embeddedAssets = data.assets?.let { assets ->
             applyAssets(
                 assets = assets,
                 applyCoverAssets = applyEmbeddedCoverAssets,
             )
         } ?: AppliedThemeAssets()
-        return AppliedThemeAssets(
+        val appliedAssets = AppliedThemeAssets(
             lightCoverPaths = embeddedAssets.lightCoverPaths.ifEmpty {
                 data.coverDefaultImage.toCoverPaths()
             },
             darkCoverPaths = embeddedAssets.darkCoverPaths.ifEmpty {
                 data.coverDefaultImageDark.toCoverPaths()
             },
+            paths = embeddedAssets.paths,
+            createdFiles = embeddedAssets.createdFiles,
+        )
+        return PreparedTheme(
+            data = data.copy(
+                bgImageLight = embeddedAssets.paths["bgImageLight"] ?: data.bgImageLight,
+                bgImageDark = embeddedAssets.paths["bgImageDark"] ?: data.bgImageDark,
+                navIconHome = embeddedAssets.paths["navIconHome"] ?: data.navIconHome,
+                navIconBookshelf = embeddedAssets.paths["navIconBookshelf"]
+                    ?: data.navIconBookshelf,
+                navIconExplore = embeddedAssets.paths["navIconExplore"] ?: data.navIconExplore,
+                navIconRss = embeddedAssets.paths["navIconRss"] ?: data.navIconRss,
+                navIconMy = embeddedAssets.paths["navIconMy"] ?: data.navIconMy,
+                appFontPath = embeddedAssets.paths["appFontPath"] ?: data.appFontPath,
+                coverDefaultImage = appliedAssets.lightCoverPaths.joinToString(","),
+                coverDefaultImageDark = appliedAssets.darkCoverPaths.joinToString(","),
+                assets = null,
+            ),
+            appliedAssets = appliedAssets,
         )
     }
 
@@ -360,6 +129,8 @@ object ThemeImportExport {
         applyCoverAssets: Boolean,
     ): AppliedThemeAssets {
         val coverPaths = mutableMapOf<String, MutableList<String>>()
+        val paths = mutableMapOf<String, String>()
+        val createdFiles = mutableListOf<File>()
 
         assets.forEach { (key, base64) ->
             if (base64.isBlank()) return@forEach
@@ -371,44 +142,39 @@ object ThemeImportExport {
                         val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
                         val folder = File(baseDir, key)
                         folder.mkdirs()
-                        File(folder, "theme_asset_${System.currentTimeMillis()}.jpg")
+                        File(folder, "theme_asset_${UUID.randomUUID()}.jpg")
                     }
 
                     key.startsWith("navIcon") -> {
                         val folder = File(appCtx.filesDir, "nav_icons")
                         folder.mkdirs()
-                        File(folder, "${key.removePrefix("navIcon").lowercase()}.png")
+                        File(
+                            folder,
+                            "theme_${key.removePrefix("navIcon").lowercase()}_${UUID.randomUUID()}.png",
+                        )
                     }
 
                     key == "appFontPath" -> {
                         val folder = File(appCtx.filesDir, "fonts")
                         folder.mkdirs()
-                        File(folder, "theme_font.ttf")
+                        File(folder, "theme_font_${UUID.randomUUID()}.ttf")
                     }
 
                     key.startsWith("coverDefaultImage") -> {
                         val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
                         val folder = File(baseDir, "covers")
                         folder.mkdirs()
-                        File(folder, "${key}_${System.currentTimeMillis()}.jpg")
+                        File(folder, "${key}_${UUID.randomUUID()}.jpg")
                     }
 
                     else -> null
                 }
 
                 destFile?.let { file ->
+                    createdFiles += file
                     FileOutputStream(file).use { it.write(bytes) }
                     val path = file.absolutePath
-                    when {
-                        key == "bgImageLight" -> ThemeConfig.bgImageLight = path
-                        key == "bgImageDark" -> ThemeConfig.bgImageDark = path
-                        key == "navIconHome" -> ThemeConfig.navIconHome = path
-                        key == "navIconBookshelf" -> ThemeConfig.navIconBookshelf = path
-                        key == "navIconExplore" -> ThemeConfig.navIconExplore = path
-                        key == "navIconRss" -> ThemeConfig.navIconRss = path
-                        key == "navIconMy" -> ThemeConfig.navIconMy = path
-                        key == "appFontPath" -> ThemeConfig.appFontPath = path
-                        key.startsWith("coverDefaultImage") -> {
+                    if (key.startsWith("coverDefaultImage")) {
                             // 判断是日间还是夜间封面
                             val baseKey = if (key.removePrefix("coverDefaultImage").startsWith("Dark")) {
                                 "coverDefaultImageDark"
@@ -416,7 +182,8 @@ object ThemeImportExport {
                                 "coverDefaultImage"
                             }
                             coverPaths.getOrPut(baseKey) { mutableListOf() }.add(path)
-                        }
+                    } else {
+                        paths[key] = path
                     }
                 }
             } catch (e: Exception) {
@@ -424,38 +191,18 @@ object ThemeImportExport {
             }
         }
 
-        // 将收集到的封面路径合并为逗号分隔的字符串
-        coverPaths["coverDefaultImage"]?.let { paths ->
-            CoverConfig.defaultCover = paths.joinToString(",")
-        }
-        coverPaths["coverDefaultImageDark"]?.let { paths ->
-            CoverConfig.defaultCoverDark = paths.joinToString(",")
-        }
         return AppliedThemeAssets(
             lightCoverPaths = coverPaths["coverDefaultImage"].orEmpty(),
             darkCoverPaths = coverPaths["coverDefaultImageDark"].orEmpty(),
+            paths = paths,
+            createdFiles = createdFiles,
         )
     }
 
-    /**
-     * 导出主题到JSON字符串
-     */
-    fun exportToJson(): String {
-        val data = exportFromCurrent()
-        return EXPORT_GSON.toJson(data)
-    }
-
-    /**
-     * 从JSON字符串导入主题
-     */
-    fun importFromJson(json: String): Boolean {
-        return importFromJsonWithAssets(json) != null
-    }
-
-    internal fun importFromJsonWithAssets(json: String): AppliedThemeAssets? {
+    internal fun prepareLegacyJson(json: String): PreparedTheme? {
         return try {
             val data = parseThemeData(json) ?: return null
-            applyToThemeConfig(data)
+            prepareForApply(data)
         } catch (e: Exception) {
             e.printStackTrace()
             null
@@ -584,163 +331,8 @@ object ThemeImportExport {
     private fun JsonObject.float(key: String, default: Float = 0f): Float =
         get(key)?.takeUnless { it.isJsonNull }?.asFloat ?: default
 
-    /**
-     * 从文件URI导入主题
-     */
-    fun importFromUri(context: Context, uri: Uri): Boolean {
-        return try {
-            val json = context.contentResolver.openInputStream(uri)?.use {
-                it.bufferedReader().readText()
-            } ?: return false
-            importFromJson(json)
-        } catch (e: Exception) {
-            e.printStackTrace()
-            false
-        }
-    }
-
-    /**
-     * 导出主题到文件
-     */
-    fun exportToFile(context: Context, uri: Uri): Boolean {
-        return try {
-            val json = exportToJson()
-            context.contentResolver.openOutputStream(uri)?.use {
-                it.write(json.toByteArray())
-            }
-            true
-        } catch (e: Exception) {
-            e.printStackTrace()
-            false
-        }
-    }
 }
 
-/**
- * 主题导出数据类
- */
-@Keep
-data class ThemeExportData(
-    // 基础主题设置
-    val appTheme: String = "0",
-    val themeMode: String = "0",
-    val isPureBlack: Boolean = false,
-    val composeEngine: String = "material",
-    val paletteStyle: String = "tonalSpot",
-    val materialVersion: String = "material3",
-    val customMode: String? = "tonalSpot",
-    val customContrast: String = "Default",
-    val launcherIcon: String = "ic_launcher",
-    val isPredictiveBackEnabled: Boolean = true,
-    val fontScale: Int = 10,
-
-    // 深度个性化颜色
-    val enableDeepPersonalization: Boolean = false,
-    val cPrimary: Int = 0,
-    val cNPrimary: Int = 0,
-    val themeColor: Int = 0,
-    val secondaryThemeColor: Int = 0,
-    val primaryTextColor: Int = 0,
-    val secondaryTextColor: Int = 0,
-    val themeBackgroundColor: Int = 0,
-    val labelContainerColor: Int = 0,
-    val themeColorNight: Int = 0,
-    val secondaryThemeColorNight: Int = 0,
-    val primaryTextColorNight: Int = 0,
-    val secondaryTextColorNight: Int = 0,
-    val themeBackgroundColorNight: Int = 0,
-    val labelContainerColorNight: Int = 0,
-    val bookInfoInputColor: Int = 0,
-    val bookInfoFollowCoverColor: Boolean = true,
-    val bookInfoBackgroundBlur: String = ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_ON,
-    val bookInfoNetworkCoverBackground: String? = null,
-    val bookInfoDefaultCoverBackground: String? = null,
-
-    // 容器设置
-    val containerOpacity: Int = 100,
-
-    // 分割线设置
-    val enableItemDivider: Boolean = false,
-    val itemDividerWidth: Float = 1f,
-    val itemDividerLength: Float = 80f,
-    val itemDividerColor: Int = 0,
-
-    // 模糊设置
-    val enableBlur: Boolean = false,
-    val enableProgressiveBlur: Boolean = false,
-    val topBarBlurRadius: Int = 24,
-    val bottomBarBlurRadius: Int = 8,
-    val topBarBlurAlpha: Int = 73,
-    val bottomBarBlurAlpha: Int = 40,
-    val bottomBarLensRadius: Float = 24f,
-
-    // 透明度设置
-    val topBarOpacity: Int = 100,
-    val bottomBarOpacity: Int = 100,
-
-    // 标签颜色
-    val enableCustomTagColors: Boolean = false,
-    val customTagColorsJson: String? = null,
-
-    // 书架卡片颜色
-    val bookshelfCardColor: Int = 0,
-    val bookshelfCardColorDark: Int = 0,
-
-    // 主界面设置
-    val showHome: Boolean = true,
-    val showDiscovery: Boolean = true,
-    val showRss: Boolean = true,
-    val showStatusBar: Boolean = true,
-    val swipeAnimation: Boolean = true,
-    val showBottomView: Boolean = true,
-    val useFloatingBottomBar: Boolean = false,
-    val useFloatingBottomBarLiquidGlass: Boolean = false,
-    val tabletInterface: String = "auto",
-    val labelVisibilityMode: String = "auto",
-    val defaultHomePage: String = "bookshelf",
-    val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
-
-    // 导航栏图标
-    val navIconHome: String = "",
-    val navIconBookshelf: String = "",
-    val navIconExplore: String = "",
-    val navIconRss: String = "",
-    val navIconMy: String = "",
-
-    // Miuix 设置
-    val useMiuixMonet: Boolean = false,
-
-    // 其他
-    val useFlexibleTopAppBar: Boolean = true,
-    val bgImageLight: String? = null,
-    val bgImageDark: String? = null,
-    val bgImageBlurring: Int = 0,
-    val bgImageNBlurring: Int = 0,
-    val appFontPath: String? = null,
-    val selectedCoverAlbumId: String? = null,
-
-    // 封面配置 (CoverConfig)
-    val coverLoadOnlyWifi: Boolean = false,
-    val coverUseDefault: Boolean = false,
-    val coverShowShadow: Boolean = false,
-    val coverShowStroke: Boolean = true,
-    val coverDefaultColor: Boolean = true,
-    val coverDefaultImage: String = "",
-    val coverTextColor: Int = -16777216,
-    val coverShadowColor: Int = -16777216,
-    val coverShowName: Boolean = true,
-    val coverShowAuthor: Boolean = true,
-    val coverDefaultImageDark: String = "",
-    val coverTextColorN: Int = -1,
-    val coverShadowColorN: Int = -1,
-    val coverShowNameN: Boolean = true,
-    val coverShowAuthorN: Boolean = true,
-    val coverInfoOrientation: String = "0",
-
-    // 嵌入的二进制资源 (Base64)
-    val assets: Map<String, String>? = null
-)
-
 /**
  * 已保存的主题
  */
@@ -755,4 +347,11 @@ data class SavedTheme(
 internal data class AppliedThemeAssets(
     val lightCoverPaths: List<String> = emptyList(),
     val darkCoverPaths: List<String> = emptyList(),
+    val paths: Map<String, String> = emptyMap(),
+    val createdFiles: List<File> = emptyList(),
+)
+
+internal data class PreparedTheme(
+    val data: ThemeExportData,
+    val appliedAssets: AppliedThemeAssets,
 )

+ 164 - 79
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -4,15 +4,19 @@ import android.content.Context
 import android.net.Uri
 import android.provider.OpenableColumns
 import androidx.annotation.Keep
+import androidx.appcompat.app.AppCompatDelegate
 import com.google.gson.JsonParser
 import com.google.gson.annotations.SerializedName
 import io.legado.app.R
+import io.legado.app.domain.gateway.ThemePackageSettingsGateway
 import io.legado.app.domain.model.CoverAlbumImageInput
+import io.legado.app.domain.model.settings.ThemeExportData
 import io.legado.app.domain.usecase.CoverAlbumUseCase
 import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.GSON
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.withContext
 import java.io.BufferedInputStream
 import java.io.BufferedOutputStream
@@ -29,8 +33,16 @@ import java.util.zip.ZipOutputStream
 class ThemePackageManager(
     private val context: Context,
     private val coverAlbumUseCase: CoverAlbumUseCase,
+    private val themePackageSettingsGateway: ThemePackageSettingsGateway,
 ) {
 
+    private val stateTransaction = ThemeStateTransaction(
+        exportSettings = themePackageSettingsGateway::exportCurrent,
+        currentAlbumId = { coverAlbumUseCase.selection.value.albumId },
+        applySettings = ::applyThemeSettings,
+        selectAlbum = coverAlbumUseCase::selectAlbum,
+    )
+
     companion object {
         const val FILE_EXTENSION = "zip"
         private const val FORMAT_VERSION = 1
@@ -63,9 +75,7 @@ class ThemePackageManager(
                 return@runSuspendCatching
             }
 
-            val rawConfig = themeData ?: ThemeImportExport.exportFromCurrent(
-                includeEmbeddedAssets = false
-            )
+            val rawConfig = themeData ?: themePackageSettingsGateway.exportCurrent()
             context.contentResolver.openOutputStream(uri)?.use { output ->
                 ZipOutputStream(BufferedOutputStream(output)).use { zip ->
                     val assetEntries = exportAssets(zip, rawConfig)
@@ -122,31 +132,33 @@ class ThemePackageManager(
                     manifest.coverSelection.albumRef,
                     albumIdMap,
                 )
-                ThemeImportExport.applyToThemeConfig(
-                    manifest.config.copy(
-                        bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
-                        bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
-                        navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
-                        navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
-                        navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
-                        navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
-                        navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
-                        appFontPath = localAssets[ASSET_FONT],
-                        coverDefaultImage = "",
-                        coverDefaultImageDark = "",
-                        assets = null,
+                stateTransaction.run {
+                    applyThemeSettings(
+                        manifest.config.copy(
+                            bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
+                            bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
+                            navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
+                            navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
+                            navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
+                            navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
+                            navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
+                            appFontPath = localAssets[ASSET_FONT],
+                            coverDefaultImage = "",
+                            coverDefaultImageDark = "",
+                            assets = null,
+                        )
                     )
-                )
-                coverAlbumUseCase.selectAlbum(selectedAlbumId)
-                saveImportedTheme(
-                    name = importedThemeName(uri, manifest.name),
-                    selectedCoverAlbumId = selectedAlbumId,
-                )
-            } catch (error: CancellationException) {
-                throw error
+                    coverAlbumUseCase.selectAlbum(selectedAlbumId)
+                    saveImportedTheme(
+                        name = importedThemeName(uri, manifest.name),
+                        selectedCoverAlbumId = selectedAlbumId,
+                    )
+                }
             } catch (error: Exception) {
-                importedAlbumIds.forEach { id ->
-                    runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                withContext(NonCancellable) {
+                    importedAlbumIds.forEach { id ->
+                        runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                    }
                 }
                 copiedAssets.forEach(File::delete)
                 throw error
@@ -161,17 +173,31 @@ class ThemePackageManager(
             val json = context.contentResolver.openInputStream(uri)?.use {
                 it.bufferedReader().readText()
             } ?: error("无法读取旧主题配置")
-            val appliedAssets = ThemeImportExport.importFromJsonWithAssets(json)
+            val preparedTheme = ThemeImportExport.prepareLegacyJson(json)
                 ?: error("旧主题配置格式无效或字段已被混淆")
-            val name = importedThemeName(uri)
-            val selectedCoverAlbumId = importLegacyCoverAlbums(
-                albumName = name,
-                appliedAssets = appliedAssets,
-            )
-            saveImportedTheme(
-                name = name,
-                selectedCoverAlbumId = selectedCoverAlbumId,
-            )
+            var selectedCoverAlbumId: String? = null
+            try {
+                stateTransaction.run {
+                    applyThemeSettings(preparedTheme.data)
+                    val name = importedThemeName(uri)
+                    selectedCoverAlbumId = importLegacyCoverAlbums(
+                        albumName = name,
+                        appliedAssets = preparedTheme.appliedAssets,
+                    )
+                    saveImportedTheme(
+                        name = name,
+                        selectedCoverAlbumId = selectedCoverAlbumId,
+                    )
+                }
+            } catch (error: Exception) {
+                withContext(NonCancellable) {
+                    selectedCoverAlbumId?.let { id ->
+                        runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                    }
+                    preparedTheme.appliedAssets.createdFiles.forEach(File::delete)
+                }
+                throw error
+            }
         }
     }
 
@@ -217,7 +243,7 @@ class ThemePackageManager(
         name: String,
         data: ThemeExportData? = null,
     ): SavedTheme = withContext(Dispatchers.IO) {
-        val themeData = data ?: ThemeImportExport.exportFromCurrent(includeEmbeddedAssets = false)
+        val themeData = data ?: themePackageSettingsGateway.exportCurrent()
         val selectedCoverAlbumId = themeData.selectedCoverAlbumId
             ?: coverAlbumUseCase.selection.value.albumId
         saveThemeFolder(
@@ -251,7 +277,9 @@ class ThemePackageManager(
                 require(packageRoot.isDirectory) {
                     "Saved theme folder does not exist: ${theme.name}"
                 }
-                applySavedThemeFolder(theme, packageRoot, packageManifest)
+                stateTransaction.run {
+                    applySavedThemeFolder(theme, packageRoot, packageManifest)
+                }
             }
         }
 
@@ -355,45 +383,57 @@ class ThemePackageManager(
         val localAssets = resolveSavedAssets(root, manifest.assets)
         val savedAlbumId = theme.data.selectedCoverAlbumId
             ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
-        val selectedAlbumId = if (savedAlbumId != null) {
-            coverAlbumUseCase.selectAlbum(savedAlbumId)
-            savedAlbumId
-        } else {
-            importSavedCoverAlbum(root, manifest)
-        }
-        ThemeImportExport.applyToThemeConfig(
-            data = manifest.config.copy(
-                bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
-                bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
-                navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
-                navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
-                navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
-                navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
-                navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
-                appFontPath = localAssets[ASSET_FONT],
-                coverDefaultImage = "",
-                coverDefaultImageDark = "",
-                selectedCoverAlbumId = selectedAlbumId,
-                assets = null,
-            ),
-            applyEmbeddedCoverAssets = false,
-        )
-        coverAlbumUseCase.selectAlbum(selectedAlbumId)
-        if (selectedAlbumId != manifest.config.selectedCoverAlbumId) {
-            writeSavedManifest(
-                root = root,
-                manifest = manifest.copy(
-                    config = manifest.config.copy(selectedCoverAlbumId = selectedAlbumId),
+        var importedAlbumIds = emptyList<String>()
+        try {
+            val selectedAlbumId = if (savedAlbumId != null) {
+                coverAlbumUseCase.selectAlbum(savedAlbumId)
+                savedAlbumId
+            } else {
+                importSavedCoverAlbum(root, manifest).also {
+                    importedAlbumIds = it.createdAlbumIds
+                }.selectedAlbumId
+            }
+            applyThemeSettings(
+                data = manifest.config.copy(
+                    bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
+                    bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
+                    navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
+                    navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
+                    navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
+                    navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
+                    navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
+                    appFontPath = localAssets[ASSET_FONT],
+                    coverDefaultImage = "",
+                    coverDefaultImageDark = "",
+                    selectedCoverAlbumId = selectedAlbumId,
+                    assets = null,
                 ),
             )
+            coverAlbumUseCase.selectAlbum(selectedAlbumId)
+            if (selectedAlbumId != manifest.config.selectedCoverAlbumId) {
+                writeSavedManifest(
+                    root = root,
+                    manifest = manifest.copy(
+                        config = manifest.config.copy(selectedCoverAlbumId = selectedAlbumId),
+                    ),
+                )
+            }
+        } catch (error: Exception) {
+            withContext(NonCancellable) {
+                importedAlbumIds.forEach { id ->
+                    runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                }
+            }
+            throw error
         }
     }
 
     private suspend fun importSavedCoverAlbum(
         root: File,
         manifest: ThemePackageManifest,
-    ): String? {
-        val albumRef = manifest.coverSelection.albumRef ?: return null
+    ): SavedCoverAlbumImport {
+        val albumRef = manifest.coverSelection.albumRef
+            ?: return SavedCoverAlbumImport(selectedAlbumId = null)
         val importedIds = mutableListOf<String>()
         return try {
             val albumIdMap = importCoverAlbums(
@@ -401,12 +441,15 @@ class ThemePackageManager(
                 albums = manifest.coverAlbums,
                 importedIds = importedIds,
             )
-            resolveAlbumRef(albumRef, albumIdMap)
-        } catch (error: CancellationException) {
-            throw error
+            SavedCoverAlbumImport(
+                selectedAlbumId = resolveAlbumRef(albumRef, albumIdMap),
+                createdAlbumIds = importedIds.toList(),
+            )
         } catch (error: Exception) {
-            importedIds.forEach { id ->
-                runCatching { coverAlbumUseCase.deleteAlbum(id) }
+            withContext(NonCancellable) {
+                importedIds.forEach { id ->
+                    runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                }
             }
             throw error
         }
@@ -694,11 +737,11 @@ class ThemePackageManager(
             }
             coverAlbumUseCase.selectAlbum(albumId)
             return albumId
-        } catch (error: CancellationException) {
-            throw error
         } catch (error: Exception) {
-            importedIds.forEach { id ->
-                runCatching { coverAlbumUseCase.deleteAlbum(id) }
+            withContext(NonCancellable) {
+                importedIds.forEach { id ->
+                    runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                }
             }
             throw error
         }
@@ -710,12 +753,24 @@ class ThemePackageManager(
     ) {
         saveTheme(
             name = uniqueSavedThemeName(name),
-            data = ThemeImportExport.exportFromCurrent().copy(
+            data = themePackageSettingsGateway.exportCurrent().copy(
                 selectedCoverAlbumId = selectedCoverAlbumId,
             ),
         )
     }
 
+    private suspend fun applyThemeSettings(data: ThemeExportData) {
+        themePackageSettingsGateway.applyAndAwait(data)
+        withContext(Dispatchers.Main.immediate) {
+            val mode = when (data.themeMode) {
+                "1" -> AppCompatDelegate.MODE_NIGHT_NO
+                "2" -> AppCompatDelegate.MODE_NIGHT_YES
+                else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+            }
+            AppCompatDelegate.setDefaultNightMode(mode)
+        }
+    }
+
     private fun uniqueSavedThemeName(name: String): String {
         if (!savedThemeDir(name).exists()) return name
         var index = 2
@@ -959,6 +1014,36 @@ class ThemePackageManager(
         val albums: List<ThemePackageCoverAlbum>,
         val selection: ThemePackageCoverSelection,
     )
+
+    private data class SavedCoverAlbumImport(
+        val selectedAlbumId: String?,
+        val createdAlbumIds: List<String> = emptyList(),
+    )
+}
+
+internal class ThemeStateTransaction(
+    private val exportSettings: () -> ThemeExportData,
+    private val currentAlbumId: () -> String?,
+    private val applySettings: suspend (ThemeExportData) -> Unit,
+    private val selectAlbum: suspend (String?) -> Unit,
+) {
+    suspend fun <T> run(block: suspend () -> T): T {
+        val previousSettings = exportSettings()
+        val previousAlbumId = currentAlbumId()
+        return try {
+            block()
+        } catch (error: Exception) {
+            withContext(NonCancellable) {
+                runCatching { applySettings(previousSettings) }
+                    .exceptionOrNull()
+                    ?.let(error::addSuppressed)
+                runCatching { selectAlbum(previousAlbumId) }
+                    .exceptionOrNull()
+                    ?.let(error::addSuppressed)
+            }
+            throw error
+        }
+    }
 }
 
 data class LegacyThemeMigrationResult(

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

@@ -8,6 +8,7 @@ import androidx.documentfile.provider.DocumentFile
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
+import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.DirectLinkUpload
@@ -34,6 +35,7 @@ import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.withContext
+import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileInputStream
@@ -48,6 +50,9 @@ import java.util.concurrent.TimeUnit
  */
 object Backup {
 
+    private val readStyleGateway: ReadStyleGateway
+        get() = GlobalContext.get().get()
+
     val backupPath: String by lazy {
         appCtx.filesDir.getFile("backup").createFolderIfNotExist().absolutePath
     }
@@ -166,11 +171,11 @@ object Backup {
             }
         }
         currentCoroutineContext().ensureActive()
-        GSON.toJson(ReadBookConfig.configList).let {
+        readStyleGateway.exportConfigsJson().let {
             FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.configFileName)
                 .writeText(it)
         }
-        GSON.toJson(ReadBookConfig.shareConfig).let {
+        readStyleGateway.exportShareConfigJson().let {
             FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName)
                 .writeText(it)
         }

+ 171 - 0
app/src/main/java/io/legado/app/help/storage/BookRestorePlanner.kt

@@ -0,0 +1,171 @@
+package io.legado.app.help.storage
+
+import io.legado.app.data.entities.Book
+import io.legado.app.help.book.isLocal
+import java.io.File
+import java.net.URI
+
+internal data class BookRestorePlan(
+    val booksToUpdate: List<Book>,
+    val booksToInsert: List<Book>,
+    val booksToDelete: List<Book>,
+) {
+    val booksToUpsert: List<Book>
+        get() = booksToUpdate + booksToInsert
+}
+
+internal enum class LocalBookLocationStatus {
+    Available,
+    Missing,
+    /** 存储离线、权限失效等原因导致无法确认,不能据此删除书籍。 */
+    Unknown,
+}
+
+/**
+ * 规划书架恢复操作,避免旧备份中的本地文件旧路径生成重复书籍。
+ */
+internal fun planBookRestore(
+    restoredBooks: List<Book>,
+    existingBooks: List<Book>,
+    ignoreLocalBook: Boolean,
+    locationStatus: (String) -> LocalBookLocationStatus,
+    normalizeLocation: (String) -> String = ::normalizeLocalBookLocation,
+): BookRestorePlan {
+    val existingBookUrls = existingBooks.mapTo(hashSetOf()) { it.bookUrl }
+    val activeBooks = existingBooks.associateByTo(linkedMapOf()) { it.bookUrl }
+    val booksToUpsert = linkedMapOf<String, Book>()
+    val booksToDelete = linkedMapOf<String, Book>()
+    val locationStatusCache = hashMapOf<String, LocalBookLocationStatus>()
+
+    fun cachedLocationStatus(bookUrl: String): LocalBookLocationStatus =
+        locationStatusCache.getOrPut(bookUrl) { locationStatus(bookUrl) }
+
+    restoredBooks.forEach { restoredBook ->
+        if (ignoreLocalBook && restoredBook.isLocal) return@forEach
+
+        val resolution = if (restoredBook.isLocal) {
+            resolveLocalBookTarget(
+                restoredBook = restoredBook,
+                activeBooks = activeBooks.values,
+                locationStatus = ::cachedLocationStatus,
+                normalizeLocation = normalizeLocation,
+            )
+        } else {
+            LocalBookResolution(restoredBook.bookUrl, emptySet())
+        }
+
+        resolution.duplicateBookUrls.forEach duplicateLoop@{ duplicateBookUrl ->
+            val duplicateBook = activeBooks.remove(duplicateBookUrl) ?: return@duplicateLoop
+            booksToUpsert.remove(duplicateBookUrl)
+            if (duplicateBookUrl in existingBookUrls) {
+                booksToDelete[duplicateBookUrl] = duplicateBook
+            }
+        }
+
+        val restoredForSave = restoredBook.copy(bookUrl = resolution.targetBookUrl)
+        booksToDelete.remove(restoredForSave.bookUrl)
+        booksToUpsert[restoredForSave.bookUrl] = restoredForSave
+        activeBooks[restoredForSave.bookUrl] = restoredForSave
+    }
+
+    val (booksToUpdate, booksToInsert) = booksToUpsert.values.partition {
+        it.bookUrl in existingBookUrls
+    }
+    return BookRestorePlan(
+        booksToUpdate = booksToUpdate,
+        booksToInsert = booksToInsert,
+        booksToDelete = booksToDelete.values.toList(),
+    )
+}
+
+private data class LocalBookResolution(
+    val targetBookUrl: String,
+    val duplicateBookUrls: Set<String>,
+)
+
+private fun resolveLocalBookTarget(
+    restoredBook: Book,
+    activeBooks: Collection<Book>,
+    locationStatus: (String) -> LocalBookLocationStatus,
+    normalizeLocation: (String) -> String,
+): LocalBookResolution {
+    val exactBook = activeBooks.firstOrNull { it.bookUrl == restoredBook.bookUrl }
+    val restoredLocation = normalizeLocation(restoredBook.bookUrl)
+    val sameLocationBooks = activeBooks.filter {
+        it.isLocal && normalizeLocation(it.bookUrl) == restoredLocation
+    }
+    val hasSameLocationAlias = sameLocationBooks.any { it.bookUrl != restoredBook.bookUrl }
+    if (sameLocationBooks.isNotEmpty() &&
+        (locationStatus(restoredBook.bookUrl) == LocalBookLocationStatus.Available ||
+                hasSameLocationAlias)
+    ) {
+        val targetBook = exactBook
+            ?: sameLocationBooks.firstOrNull {
+                locationStatus(it.bookUrl) == LocalBookLocationStatus.Available
+            }
+            ?: sameLocationBooks.first()
+        return LocalBookResolution(
+            targetBookUrl = targetBook.bookUrl,
+            duplicateBookUrls = sameLocationBooks
+                .asSequence()
+                .map { it.bookUrl }
+                .filterNot { it == targetBook.bookUrl }
+                .toSet(),
+        )
+    }
+
+    val compatibleBooks = activeBooks.filter { it.isRelocatedCopyOf(restoredBook) }
+    val candidateBookUrls = buildSet {
+        add(restoredBook.bookUrl)
+        compatibleBooks.forEach { add(it.bookUrl) }
+    }
+    val candidateStatuses = candidateBookUrls.associateWith(locationStatus)
+    val availableLocations = candidateStatuses
+        .filterValues { it == LocalBookLocationStatus.Available }
+        .keys
+    val hasUnknownLocation = candidateStatuses.values.any {
+        it == LocalBookLocationStatus.Unknown
+    }
+    if (!hasUnknownLocation && availableLocations.size == 1) {
+        val targetBookUrl = availableLocations.single()
+        return LocalBookResolution(
+            targetBookUrl = targetBookUrl,
+            duplicateBookUrls = compatibleBooks
+                .asSequence()
+                .map { it.bookUrl }
+                .filterNot { it == targetBookUrl }
+                .filter {
+                    candidateStatuses[it] == LocalBookLocationStatus.Missing
+                }
+                .toSet(),
+        )
+    }
+
+    return LocalBookResolution(
+        targetBookUrl = exactBook?.bookUrl ?: restoredBook.bookUrl,
+        duplicateBookUrls = emptySet(),
+    )
+}
+
+private fun Book.isRelocatedCopyOf(other: Book): Boolean {
+    return isLocal &&
+            originName.isNotBlank() &&
+            originName == other.originName &&
+            name.isNotBlank() &&
+            name == other.name &&
+            author == other.author
+}
+
+internal fun normalizeLocalBookLocation(bookUrl: String): String {
+    val value = bookUrl.trim()
+    if (value.isEmpty()) return value
+    if (value.startsWith("content://", ignoreCase = true)) return value
+    val path = if (value.startsWith("file://", ignoreCase = true)) {
+        runCatching { File(URI(value)).path }
+            .getOrElse { value.substringAfter("file://") }
+    } else {
+        value
+    }
+    return runCatching { File(path).canonicalPath }
+        .getOrElse { File(path).absolutePath }
+}

+ 44 - 19
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -3,6 +3,8 @@ package io.legado.app.help.storage
 import android.content.Context
 import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
+import android.os.Environment
+import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.BuildConfig
 import io.legado.app.R
@@ -53,6 +55,7 @@ import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isUri
 import io.legado.app.utils.openInputStream
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers.Main
@@ -116,29 +119,26 @@ object Restore : KoinComponent {
             it.forEach { book ->
                 book.upType()
             }
-            it.filter { book -> book.isLocal }
-                .forEach { book ->
-                    book.coverUrl = LocalBook.getCoverPath(book)
+            val restorePlan = planBookRestore(
+                restoredBooks = it,
+                existingBooks = appDb.bookDao.all,
+                ignoreLocalBook = BackupConfig.ignoreLocalBook,
+                locationStatus = ::localBookLocationStatus,
+            )
+            restorePlan.booksToUpsert
+                .filter { book -> book.isLocal }
+                .forEach { book -> book.coverUrl = LocalBook.getCoverPath(book) }
+            appDb.runInTransaction {
+                if (restorePlan.booksToDelete.isNotEmpty()) {
+                    appDb.bookDao.delete(*restorePlan.booksToDelete.toTypedArray())
                 }
-            val newBooks = arrayListOf<Book>()
-            val ignoreLocalBook = BackupConfig.ignoreLocalBook
-            it.forEach { book ->
-                if (ignoreLocalBook && book.isLocal) {
-                    return@forEach
+                if (restorePlan.booksToUpdate.isNotEmpty()) {
+                    appDb.bookDao.update(*restorePlan.booksToUpdate.toTypedArray())
                 }
-                if (appDb.bookDao.has(book.bookUrl)) {
-                    try {
-                        appDb.bookDao.update(book)
-                    } catch (_: SQLiteConstraintException) {
-                        appDb.bookDao.insert(book)
-                    }
-                } else {
-                    newBooks.add(book)
+                if (restorePlan.booksToInsert.isNotEmpty()) {
+                    appDb.bookDao.insert(*restorePlan.booksToInsert.toTypedArray())
                 }
             }
-            if (newBooks.isNotEmpty()) {
-                appDb.bookDao.insert(*newBooks.toTypedArray())
-            }
         }
         fileToListT<Bookmark>(path, "bookmark.json")?.let {
             try {
@@ -343,6 +343,31 @@ object Restore : KoinComponent {
         }
     }
 
+    private fun localBookLocationStatus(bookUrl: String): LocalBookLocationStatus {
+        val uri = bookUrl.takeIf { it.isUri() }?.toUri()
+        if (uri?.isContentScheme() == true) {
+            // Provider 离线、临时权限问题与文件确实删除无法可靠区分,失败时保守保留记录。
+            return kotlin.runCatching {
+                if (appCtx.contentResolver.openInputStream(uri)?.use { true } == true) {
+                    LocalBookLocationStatus.Available
+                } else {
+                    LocalBookLocationStatus.Unknown
+                }
+            }.getOrDefault(LocalBookLocationStatus.Unknown)
+        }
+
+        val file = File(uri?.path ?: bookUrl)
+        if (file.isFile) return LocalBookLocationStatus.Available
+        return when (runCatching { Environment.getExternalStorageState(file) }.getOrNull()) {
+            Environment.MEDIA_MOUNTED,
+            Environment.MEDIA_MOUNTED_READ_ONLY -> LocalBookLocationStatus.Missing
+
+            Environment.MEDIA_UNKNOWN -> LocalBookLocationStatus.Unknown
+            null -> LocalBookLocationStatus.Unknown
+            else -> LocalBookLocationStatus.Unknown
+        }
+    }
+
     private suspend fun restoreReadRecord(readRecord: ReadRecord) {
         val existing = appDb.readRecordDao.getReadRecord(
             readRecord.deviceId,

+ 26 - 4
app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt

@@ -14,17 +14,28 @@ import androidx.core.view.ViewCompat
 import androidx.preference.PreferenceViewHolder
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import androidx.lifecycle.lifecycleScope
 import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.ThemeIntSetting
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.toastOnUi
+import io.legado.app.utils.activity
+import kotlinx.coroutines.launch
+import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 
 @SuppressLint("ResourceType")
 class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs) {
 
+    private val themeSettingsGateway by lazy {
+        GlobalContext.get().get<ThemeSettingsGateway>()
+    }
+
     private var entries: Array<CharSequence> = context.resources.getTextArray(R.array.themes_item)
     private var entryValues: Array<CharSequence> = context.resources.getTextArray(R.array.themes_value).takeIf { it.isNotEmpty() }
         ?: arrayOf("0")
@@ -36,7 +47,7 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
     }
 
     override fun onSetInitialValue(defaultValue: Any?) {
-        currentValue = getPersistedString(defaultValue as? String ?: entryValues.getOrNull(0)?.toString())
+        currentValue = themeSettingsGateway.currentSettings.appTheme
     }
 
     override fun onBindViewHolder(holder: PreferenceViewHolder) {
@@ -89,13 +100,24 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                             context.toastOnUi(R.string.transparent_theme_alarm)
                             return@setOnClickListener
                         } else {
-                            ThemeConfig.containerOpacity = 0
                         }
                     }
                     currentValue = value
-                    persistString(value)
                     callChangeListener(value)
-                    ThemeConfig.appTheme = value
+                    holder.itemView.activity?.lifecycleScope?.launch {
+                        val updates = buildList {
+                            add(ThemeSettingsUpdate.AppTheme(value))
+                            if (value == "13") {
+                                add(
+                                    ThemeSettingsUpdate.IntValue(
+                                        ThemeIntSetting.ContainerOpacity,
+                                        0,
+                                    )
+                                )
+                            }
+                        }
+                        themeSettingsGateway.updateAll(updates)
+                    }
                     notifyDataSetChanged()
                 }
             }

+ 3 - 1
app/src/main/java/io/legado/app/model/AudioPlay.kt

@@ -18,6 +18,7 @@ import io.legado.app.help.book.getBookSource
 import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.update
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.AudioPlayService
@@ -374,7 +375,8 @@ object AudioPlay : CoroutineScope by MainScope() {
                 appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex)?.let {
                     book.durChapterTitle = it.getDisplayTitle(
                         ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                        book.getUseReplaceRule()
+                        book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                        chineseConverterType = AppConfig.chineseConverterType,
                     )
                     SourceCallBack.callBackBook(SourceCallBack.SAVE_READ, bookSource, book, it)
                 }

+ 14 - 18
app/src/main/java/io/legado/app/model/CheckSource.kt

@@ -4,22 +4,27 @@ import android.content.Context
 import io.legado.app.R
 import io.legado.app.constant.IntentAction
 import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.help.CacheManager
+import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.help.IntentData
 import io.legado.app.service.CheckSourceService
 import io.legado.app.utils.startService
+import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 
 object CheckSource {
     var keyword = "我的"
 
-    //校验设置
-    var timeout = CacheManager.getLong("checkSourceTimeout") ?: 180000L
-    var checkSearch = CacheManager.get("checkSearch")?.toBoolean() ?: true
-    var checkDiscovery = CacheManager.get("checkDiscovery")?.toBoolean() ?: true
-    var checkInfo = CacheManager.get("checkInfo")?.toBoolean() ?: true
-    var checkCategory = CacheManager.get("checkCategory")?.toBoolean() ?: true
-    var checkContent = CacheManager.get("checkContent")?.toBoolean() ?: true
+    private val settingsGateway: CheckSourceSettingsGateway
+        get() = GlobalContext.get().get()
+    private val settings get() = settingsGateway.currentSettings
+
+    // Legacy services keep synchronous reads; writes are owned by CheckSourceSettingsGateway.
+    val timeout get() = settings.timeoutMillis
+    val checkSearch get() = settings.checkSearch
+    val checkDiscovery get() = settings.checkDiscovery
+    val checkInfo get() = settings.checkInfo
+    val checkCategory get() = settings.checkCategory
+    val checkContent get() = settings.checkContent
     val summary get() = upSummary()
 
     fun start(context: Context, sources: List<BookSourcePart>) {
@@ -44,15 +49,6 @@ object CheckSource {
         }
     }
 
-    fun putConfig() {
-        CacheManager.put("checkSourceTimeout", timeout)
-        CacheManager.put("checkSearch", checkSearch)
-        CacheManager.put("checkDiscovery", checkDiscovery)
-        CacheManager.put("checkInfo", checkInfo)
-        CacheManager.put("checkCategory", checkCategory)
-        CacheManager.put("checkContent", checkContent)
-    }
-
     private fun upSummary(): String {
         var checkItem = ""
         if (checkSearch) checkItem = "$checkItem ${appCtx.getString(R.string.search)}"
@@ -66,4 +62,4 @@ object CheckSource {
             checkItem
         )
     }
-}
+}

+ 4 - 5
app/src/main/java/io/legado/app/model/ImageProvider.kt

@@ -41,10 +41,9 @@ object ImageProvider {
     private const val M = 1024 * 1024
     val cacheSize: Int
         get() {
-            if (AppConfig.bitmapCacheSize !in 1..<2048) {
-                AppConfig.bitmapCacheSize = 50
-            }
-            return AppConfig.bitmapCacheSize * M
+            return AppConfig.bitmapCacheSize.takeIf { it in 1..<2048 }
+                ?.times(M)
+                ?: (50 * M)
         }
 
     val bitmapLruCache = BitmapLruCache()
@@ -185,7 +184,7 @@ object ImageProvider {
         height: Int? = null
     ): Bitmap {
         //src为空白时 可能被净化替换掉了 或者规则失效
-        if (book.getUseReplaceRule() && src.isBlank()) {
+        if (book.getUseReplaceRule(AppConfig.replaceEnableDefault) && src.isBlank()) {
             book.setUseReplaceRule(false)
             appCtx.toastOnUi(R.string.error_image_url_empty)
         }

+ 8 - 4
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -22,6 +22,7 @@ import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.update
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
@@ -213,7 +214,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun upReadBookConfig(book: Book) {
         val oldIndex = ReadBookConfig.styleSelect
-        ReadBookConfig.isComic = book.isImage
+        ReadSessionState.isComic = book.isImage
         if (oldIndex != ReadBookConfig.styleSelect) {
             postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
             if (ReadConfig.readBarStyleFollowPage) {
@@ -991,7 +992,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             val contentProcessor = ContentProcessor.get(book.name, book.origin)
             val displayTitle = chapter.getDisplayTitle(
                 contentProcessor.getTitleReplaceRules(),
-                book.getUseReplaceRule()
+                book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                chineseConverterType = AppConfig.chineseConverterType,
             )
             val contents = contentProcessor
                 .getContent(book, chapter, content, includeTitle = false)
@@ -1099,7 +1101,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             val contentProcessor = ContentProcessor.get(book.name, book.origin)
             val displayTitle = chapter.getDisplayTitle(
                 contentProcessor.getTitleReplaceRules(),
-                book.getUseReplaceRule()
+                book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                chineseConverterType = AppConfig.chineseConverterType,
             )
             val contents = contentProcessor
                 .getContent(book, chapter, content, includeTitle = false)
@@ -1215,7 +1218,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
                         book.durChapterTitle = it.getDisplayTitle(
                             ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                            book.getUseReplaceRule()
+                            book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                            chineseConverterType = AppConfig.chineseConverterType,
                         )
                         SourceCallBack.callBackBook(SourceCallBack.SAVE_READ, bookSource, book, it)
                     }

+ 3 - 1
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -19,6 +19,7 @@ import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.update
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
 import io.legado.app.model.webBook.WebBook
@@ -431,7 +432,8 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
                     appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
                         book.durChapterTitle = it.getDisplayTitle(
                             ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                            book.getUseReplaceRule()
+                            book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+                            chineseConverterType = AppConfig.chineseConverterType,
                         )
                     }
                 }

+ 35 - 0
app/src/main/java/io/legado/app/model/ReadSessionState.kt

@@ -0,0 +1,35 @@
+package io.legado.app.model
+
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.utils.getMeanColor
+
+/** 仅在当前阅读会话内有效的渲染资源,不参与持久化。 */
+object ReadSessionState {
+    var isComic: Boolean = false
+        internal set
+
+    @Volatile
+    var isDarkThemeOverride: Boolean? = null
+        internal set
+
+    var lastNavigationBarHeight: Int = 0
+
+    var background: Drawable? = null
+        private set
+
+    var backgroundMeanColor: Int = 0
+        private set
+
+    fun updateBackground(width: Int, height: Int) {
+        val drawable = ReadBookConfig.durConfig.curBgDrawable(width, height)
+        backgroundMeanColor = when (drawable) {
+            is BitmapDrawable -> drawable.bitmap?.getMeanColor() ?: 0
+            is ColorDrawable -> drawable.color
+            else -> 0
+        }
+        background = drawable
+    }
+}

+ 2 - 1
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -17,6 +17,7 @@ import io.legado.app.data.entities.RssArticle
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.JsExtensions
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.http.BackstageWebView
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.getShareScope
@@ -175,7 +176,7 @@ class AnalyzeRule(
                 url = baseUrl,
                 html = content.toString(),
                 javaScript = jsStr,
-                headerMap = getSource()?.getHeaderMap(true),
+                headerMap = getSource()?.getHeaderMap(AppConfig.userAgent, true),
                 tag = getSource()?.getKey(),
                 cacheFirst = true,
                 timeout = 10000,

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