Эх сурвалжийг харах

重构应用配置为 UDF 状态流

5151561 3 сар өмнө
parent
commit
ac4ff0a175
33 өөрчлөгдсөн 784 нэмэгдсэн , 158 устгасан
  1. 39 21
      app/src/main/AndroidManifest.xml
  2. 7 21
      app/src/main/java/io/legado/app/App.kt
  3. 164 13
      app/src/main/java/io/legado/app/base/BaseActivity.kt
  4. 69 6
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  5. 18 0
      app/src/main/java/io/legado/app/base/LegacyUiConfiguration.kt
  6. 0 1
      app/src/main/java/io/legado/app/constant/EventBus.kt
  7. 103 0
      app/src/main/java/io/legado/app/data/repository/AppLocaleRepository.kt
  8. 42 0
      app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt
  9. 35 32
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  10. 9 1
      app/src/main/java/io/legado/app/di/appModule.kt
  11. 14 0
      app/src/main/java/io/legado/app/domain/gateway/AppLocaleGateway.kt
  12. 0 1
      app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt
  13. 9 0
      app/src/main/java/io/legado/app/domain/gateway/AppUiConfigurationGateway.kt
  14. 0 1
      app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt
  15. 0 1
      app/src/main/java/io/legado/app/domain/model/settings/AppShellSettings.kt
  16. 41 0
      app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt
  17. 0 1
      app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt
  18. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  19. 2 4
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  20. 3 3
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  21. 0 4
      app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt
  22. 0 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  23. 0 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  24. 1 11
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt
  25. 0 1
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt
  26. 0 4
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt
  27. 8 5
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  28. 3 8
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  29. 1 2
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt
  30. 42 0
      app/src/test/java/io/legado/app/base/LegacyUiConfigurationTest.kt
  31. 99 0
      app/src/test/java/io/legado/app/data/repository/AppLocaleRepositoryTest.kt
  32. 51 0
      app/src/test/java/io/legado/app/domain/model/settings/AppUiConfigurationTest.kt
  33. 23 15
      app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

+ 39 - 21
app/src/main/AndroidManifest.xml

@@ -39,7 +39,7 @@
         <activity
         <activity
             android:name=".ui.main.LauncherW"
             android:name=".ui.main.LauncherW"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcherw">
             android:icon="@mipmap/launcherw">
             <intent-filter>
             <intent-filter>
@@ -55,7 +55,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher0"
             android:name=".ui.main.Launcher0"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher0">
             android:icon="@mipmap/launcher0">
             <intent-filter>
             <intent-filter>
@@ -72,7 +72,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher1"
             android:name=".ui.main.Launcher1"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher1">
             android:icon="@mipmap/launcher1">
             <intent-filter>
             <intent-filter>
@@ -89,7 +89,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher2"
             android:name=".ui.main.Launcher2"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher2">
             android:icon="@mipmap/launcher2">
             <intent-filter>
             <intent-filter>
@@ -106,7 +106,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher3"
             android:name=".ui.main.Launcher3"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher3">
             android:icon="@mipmap/launcher3">
             <intent-filter>
             <intent-filter>
@@ -123,7 +123,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher4"
             android:name=".ui.main.Launcher4"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher4">
             android:icon="@mipmap/launcher4">
             <intent-filter>
             <intent-filter>
@@ -140,7 +140,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher5"
             android:name=".ui.main.Launcher5"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher5">
             android:icon="@mipmap/launcher5">
             <intent-filter>
             <intent-filter>
@@ -157,7 +157,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher6"
             android:name=".ui.main.Launcher6"
             android:enabled="false"
             android:enabled="false"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher6">
             android:icon="@mipmap/launcher6">
             <intent-filter>
             <intent-filter>
@@ -174,7 +174,7 @@
         <activity
         <activity
             android:name=".ui.main.MainActivity"
             android:name=".ui.main.MainActivity"
             android:alwaysRetainTaskState="true"
             android:alwaysRetainTaskState="true"
-            android:configChanges="locale|layoutDirection|uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:windowSoftInputMode="adjustResize"
             android:exported="true">
             android:exported="true">
@@ -190,12 +190,14 @@
         </activity>
         </activity>
         <activity
         <activity
             android:name=".ui.welcome.WelcomeActivity"
             android:name=".ui.welcome.WelcomeActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:exported="false"
             android:exported="false"
             android:windowSoftInputMode="adjustPan|stateHidden" />
             android:windowSoftInputMode="adjustPan|stateHidden" />
         <!-- 阅读漫画界面 -->
         <!-- 阅读漫画界面 -->
         <activity
         <activity
             android:name=".ui.book.manga.ReadMangaActivity"
             android:name=".ui.book.manga.ReadMangaActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="false"
             android:exported="false"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask">
             android:launchMode="singleTask">
@@ -210,17 +212,20 @@
         <!-- 书籍详情页 -->
         <!-- 书籍详情页 -->
         <activity
         <activity
             android:name=".ui.book.info.BookInfoActivity"
             android:name=".ui.book.info.BookInfoActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 书籍信息编辑 -->
         <!-- 书籍信息编辑 -->
         <activity
         <activity
             android:name=".ui.book.info.edit.BookInfoEditActivity"
             android:name=".ui.book.info.edit.BookInfoEditActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 音频播放界面 -->
         <!-- 音频播放界面 -->
         <activity
         <activity
             android:name="io.legado.app.ui.book.audio.AudioPlayActivity"
             android:name="io.legado.app.ui.book.audio.AudioPlayActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask" />
             android:launchMode="singleTask" />
         <!-- 授权界面 -->
         <!-- 授权界面 -->
@@ -231,113 +236,125 @@
         <!-- 二维码扫描 -->
         <!-- 二维码扫描 -->
         <activity
         <activity
             android:name=".ui.qrcode.QrCodeActivity"
             android:name=".ui.qrcode.QrCodeActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask" />
             android:launchMode="singleTask" />
         <!-- 书源编辑 -->
         <!-- 书源编辑 -->
         <activity
         <activity
             android:name=".ui.book.source.edit.BookSourceEditActivity"
             android:name=".ui.book.source.edit.BookSourceEditActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="singleTask"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 订阅源编辑 -->
         <!-- 订阅源编辑 -->
         <activity
         <activity
             android:name=".ui.rss.source.edit.RssSourceEditActivity"
             android:name=".ui.rss.source.edit.RssSourceEditActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="singleTask"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 搜索界面 -->
         <!-- 搜索界面 -->
         <activity
         <activity
             android:name=".ui.book.search.SearchActivity"
             android:name=".ui.book.search.SearchActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="standard" />
             android:launchMode="standard" />
         <activity
         <activity
             android:name=".ui.about.CrashReportActivity"
             android:name=".ui.about.CrashReportActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:excludeFromRecents="true"
             android:excludeFromRecents="true"
             android:launchMode="singleTask" />
             android:launchMode="singleTask" />
         <!-- 书源管理 -->
         <!-- 书源管理 -->
         <activity
         <activity
             android:name=".ui.book.source.manage.BookSourceActivity"
             android:name=".ui.book.source.manage.BookSourceActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="false"
             android:enableOnBackInvokedCallback="false"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 订阅源管理 -->
         <!-- 订阅源管理 -->
         <activity
         <activity
             android:name=".ui.rss.source.manage.RssSourceActivity"
             android:name=".ui.rss.source.manage.RssSourceActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- txt目录规则管理 -->
         <!-- txt目录规则管理 -->
         <activity
         <activity
             android:name=".ui.book.toc.rule.TxtTocRuleActivity"
             android:name=".ui.book.toc.rule.TxtTocRuleActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- txt目录规则预览(阅读界面) -->
         <!-- txt目录规则预览(阅读界面) -->
         <activity
         <activity
             android:name=".ui.book.toc.rule.preview.TxtTocRulePreviewActivity"
             android:name=".ui.book.toc.rule.preview.TxtTocRulePreviewActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 替换规则界面 -->
         <!-- 替换规则界面 -->
         <activity
         <activity
             android:name=".ui.replace.ReplaceRuleActivity"
             android:name=".ui.replace.ReplaceRuleActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:windowSoftInputMode="adjustResize"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 书源调试 -->
         <!-- 书源调试 -->
         <activity
         <activity
             android:name=".ui.book.source.debug.BookSourceDebugActivity"
             android:name=".ui.book.source.debug.BookSourceDebugActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 目录 -->
         <!-- 目录 -->
         <activity
         <activity
             android:name=".ui.book.toc.TocActivity"
             android:name=".ui.book.toc.TocActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 订阅源调试 -->
         <!-- 订阅源调试 -->
         <activity
         <activity
             android:name=".ui.rss.source.debug.RssSourceDebugActivity"
             android:name=".ui.rss.source.debug.RssSourceDebugActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- 书签 -->
         <!-- 书签 -->
         <activity
         <activity
             android:name=".ui.book.bookmark.AllBookmarkActivity"
             android:name=".ui.book.bookmark.AllBookmarkActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
         <!-- WebView界面 -->
         <!-- WebView界面 -->
         <activity
         <activity
             android:name=".ui.browser.WebViewActivity"
             android:name=".ui.browser.WebViewActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="standard" />
             android:launchMode="standard" />
         <!-- 书源登录 -->
         <!-- 书源登录 -->
         <activity
         <activity
             android:name="io.legado.app.ui.login.SourceLoginActivity"
             android:name="io.legado.app.ui.login.SourceLoginActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true"
             android:hardwareAccelerated="true"
             android:theme="@style/AppTheme.Transparent" />
             android:theme="@style/AppTheme.Transparent" />
         <!-- 字典管理 -->
         <!-- 字典管理 -->
         <activity
         <activity
             android:name=".ui.dict.rule.DictRuleActivity"
             android:name=".ui.dict.rule.DictRuleActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
             android:hardwareAccelerated="true" />
         <activity
         <activity
             android:name=".ui.dict.DictActivity"
             android:name=".ui.dict.DictActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
             android:hardwareAccelerated="true" />
         <!-- 文件管理 -->
         <!-- 文件管理 -->
         <activity
         <activity
             android:name=".ui.file.FileManageActivity"
             android:name=".ui.file.FileManageActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
             android:hardwareAccelerated="true" />
         <!-- 选择文件 -->
         <!-- 选择文件 -->
         <activity
         <activity
             android:name=".ui.file.HandleFileActivity"
             android:name=".ui.file.HandleFileActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true"
             android:hardwareAccelerated="true"
             android:theme="@style/AppTheme.Transparent" />
             android:theme="@style/AppTheme.Transparent" />
         <!-- 文字处理 -->
         <!-- 文字处理 -->
@@ -364,7 +381,7 @@
         <!-- 一键导入 -->
         <!-- 一键导入 -->
         <activity
         <activity
             android:name=".ui.association.OnLineImportActivity"
             android:name=".ui.association.OnLineImportActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:exported="true"
             android:exported="true"
             android:theme="@style/AppTheme.Transparent">
             android:theme="@style/AppTheme.Transparent">
             <intent-filter>
             <intent-filter>
@@ -380,16 +397,17 @@
         <!-- 验证码输入 -->
         <!-- 验证码输入 -->
         <activity
         <activity
             android:name=".ui.association.VerificationCodeActivity"
             android:name=".ui.association.VerificationCodeActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:theme="@style/AppTheme.Transparent" />
             android:theme="@style/AppTheme.Transparent" />
         <!-- 跳转确认 -->
         <!-- 跳转确认 -->
         <activity
         <activity
             android:name=".ui.association.OpenUrlConfirmActivity"
             android:name=".ui.association.OpenUrlConfirmActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:theme="@style/AppTheme.Transparent" />
             android:theme="@style/AppTheme.Transparent" />
         <!-- 打开文件 -->
         <!-- 打开文件 -->
         <activity
         <activity
             android:name=".ui.association.FileAssociationActivity"
             android:name=".ui.association.FileAssociationActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:exported="true"
             android:launchMode="singleTop"
             android:launchMode="singleTop"
             android:theme="@style/AppTheme.Transparent">
             android:theme="@style/AppTheme.Transparent">
@@ -517,7 +535,7 @@
 
 
         <service
         <service
             android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
             android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
-            android:enabled="true"
+            android:enabled="false"
             android:exported="false"
             android:exported="false"
             tools:ignore="ExportedService">
             tools:ignore="ExportedService">
             <meta-data
             <meta-data

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

@@ -6,10 +6,8 @@ import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.app.NotificationManager
 import android.content.Context
 import android.content.Context
 import android.content.pm.ApplicationInfo
 import android.content.pm.ApplicationInfo
-import android.content.res.Configuration
 import android.graphics.BitmapFactory
 import android.graphics.BitmapFactory
 import android.os.Build
 import android.os.Build
-import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.graphics.scale
 import androidx.core.graphics.scale
 import coil.ImageLoader
 import coil.ImageLoader
 import coil.ImageLoaderFactory
 import coil.ImageLoaderFactory
@@ -38,6 +36,7 @@ import io.legado.app.data.entities.rule.SearchRule
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appModule
 import io.legado.app.di.appModule
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
 import io.legado.app.help.CrashHandler
@@ -63,7 +62,6 @@ import io.legado.app.lib.theme.primaryColor
 import io.legado.app.model.BookCover
 import io.legado.app.model.BookCover
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.config.otherConfig.appLocaleListFor
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.LogUtils
@@ -86,8 +84,6 @@ import java.util.logging.Level
 
 
 class App : Application(), ImageLoaderFactory {
 class App : Application(), ImageLoaderFactory {
 
 
-    private lateinit var oldConfig: Configuration
-
     override fun newImageLoader(): ImageLoader {
     override fun newImageLoader(): ImageLoader {
         return get()
         return get()
     }
     }
@@ -100,17 +96,17 @@ class App : Application(), ImageLoaderFactory {
         // autoStoreLocales 持久化。不能每次启动都执行——API 33+ 上会覆盖用户在
         // autoStoreLocales 持久化。不能每次启动都执行——API 33+ 上会覆盖用户在
         // 系统设置里选择的应用语言,API <33 上此时 AppCompat 存储尚未加载、
         // 系统设置里选择的应用语言,API <33 上此时 AppCompat 存储尚未加载、
         // getApplicationLocales() 恒为空,isEmpty 守卫会形同虚设
         // getApplicationLocales() 恒为空,isEmpty 守卫会形同虚设
-        if (!LocalConfig.appLocaleMigrated) {
+        val legacyLanguage = if (!LocalConfig.appLocaleMigrated) {
             LocalConfig.appLocaleMigrated = true
             LocalConfig.appLocaleMigrated = true
-            val localeList = appLocaleListFor(OtherConfig.language)
-            if (!localeList.isEmpty && AppCompatDelegate.getApplicationLocales().isEmpty) {
-                AppCompatDelegate.setApplicationLocales(localeList)
-            }
-        }
+            OtherConfig.language
+        } else null
         startKoin {
         startKoin {
             androidContext(this@App)
             androidContext(this@App)
             modules(appDatabaseModule, appModule)
             modules(appDatabaseModule, appModule)
         }
         }
+        if (legacyLanguage != null) {
+            get<AppLocaleGateway>().migrateLegacyLanguage(legacyLanguage)
+        }
         applyDayNightInit(this)
         applyDayNightInit(this)
         if (getPrefString("app_theme", "0") == "12") {
         if (getPrefString("app_theme", "0") == "12") {
             if (AppConfig.customMode == "accent")
             if (AppConfig.customMode == "accent")
@@ -150,7 +146,6 @@ class App : Application(), ImageLoaderFactory {
         if (isDebuggable) {
         if (isDebuggable) {
             ThreadUtils.setThreadAssertsDisabledForTesting(true)
             ThreadUtils.setThreadAssertsDisabledForTesting(true)
         }
         }
-        oldConfig = Configuration(resources.configuration)
         registerActivityLifecycleCallbacks(LifecycleHelp)
         registerActivityLifecycleCallbacks(LifecycleHelp)
         Coroutine.async {
         Coroutine.async {
             get<BackupSettingsGateway>().settings
             get<BackupSettingsGateway>().settings
@@ -210,15 +205,6 @@ class App : Application(), ImageLoaderFactory {
         }
         }
     }
     }
 
 
-//    override fun onConfigurationChanged(newConfig: Configuration) {
-//        super.onConfigurationChanged(newConfig)
-//        val diff = newConfig.diff(oldConfig)
-//        if ((diff and ActivityInfo.CONFIG_UI_MODE) != 0) {
-//            applyDayNight(this)
-//        }
-//        oldConfig = Configuration(newConfig)
-//    }
-
     override fun onTrimMemory(level: Int) {
     override fun onTrimMemory(level: Int) {
         super.onTrimMemory(level)
         super.onTrimMemory(level)
         TextLine.trimCaches(level)
         TextLine.trimCaches(level)

+ 164 - 13
app/src/main/java/io/legado/app/base/BaseActivity.kt

@@ -14,20 +14,29 @@ import android.view.View
 import android.window.OnBackInvokedDispatcher
 import android.window.OnBackInvokedDispatcher
 import androidx.activity.enableEdgeToEdge
 import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
+import androidx.core.os.LocaleListCompat
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.scale
 import androidx.core.graphics.scale
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.viewbinding.ViewBinding
 import androidx.viewbinding.ViewBinding
 import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColorsOptions
 import com.google.android.material.color.DynamicColorsOptions
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.BuildConfig
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Theme
 import io.legado.app.constant.Theme
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.model.settings.AppUiConfigurationDiff
+import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.utils.applyOpenTint
 import io.legado.app.utils.applyOpenTint
 import io.legado.app.utils.applyTint
 import io.legado.app.utils.applyTint
@@ -35,12 +44,14 @@ import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hideSoftInput
 import io.legado.app.utils.hideSoftInput
-import io.legado.app.utils.observeEvent
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.windowSize
 import io.legado.app.utils.windowSize
 import java.io.File
 import java.io.File
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
 
 
 
 
 abstract class BaseActivity<VB : ViewBinding>(
 abstract class BaseActivity<VB : ViewBinding>(
@@ -50,6 +61,17 @@ abstract class BaseActivity<VB : ViewBinding>(
     private val imageBg: Boolean = true
     private val imageBg: Boolean = true
 ) : AppCompatActivity() {
 ) : AppCompatActivity() {
 
 
+    protected open val legacyUiConfigurationPolicy =
+        LegacyUiConfigurationPolicy.ControlledRecreate
+
+    private val appLocaleGateway by inject<AppLocaleGateway>()
+    private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private var lastUiConfiguration: AppUiConfiguration? = null
+    private var lastPlatformConfiguration: Configuration? = null
+    private var pendingControlledRecreate = false
+    private var recreatePosted = false
+    private var hasWindowBackgroundImage = false
+
     protected abstract val binding: VB
     protected abstract val binding: VB
 
 
     val isInMultiWindow: Boolean
     val isInMultiWindow: Boolean
@@ -94,6 +116,8 @@ abstract class BaseActivity<VB : ViewBinding>(
         }
         }
 
 
         super.onCreate(savedInstanceState)
         super.onCreate(savedInstanceState)
+        lastPlatformConfiguration = Configuration(resources.configuration)
+        lastUiConfiguration = appUiConfigurationGateway.currentConfiguration
         WindowCompat.setDecorFitsSystemWindows(window, false)
         WindowCompat.setDecorFitsSystemWindows(window, false)
         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
             enableEdgeToEdge()
             enableEdgeToEdge()
@@ -108,6 +132,8 @@ abstract class BaseActivity<VB : ViewBinding>(
 
 
 
 
         observeLiveBus()
         observeLiveBus()
+        observeAppUiConfiguration()
+        traceConfiguration("onCreate")
         //onActivityCreated(savedInstanceState)
         //onActivityCreated(savedInstanceState)
     }
     }
 
 
@@ -132,13 +158,43 @@ abstract class BaseActivity<VB : ViewBinding>(
         WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !isDarkTheme
         WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !isDarkTheme
     }
     }
 
 
-//    override fun onConfigurationChanged(newConfig: Configuration) {
-//        super.onConfigurationChanged(newConfig)
-//        //findViewById<TitleBar>(R.id.title_bar)
-//        //Log.d("Config", "uiMode = ${newConfig.uiMode}")
-//            //?.onMultiWindowModeChanged(isInMultiWindow, fullScreen)
-//        //setupSystemBar()
-//    }
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        val previous = lastPlatformConfiguration
+        super.onConfigurationChanged(newConfig)
+        lastPlatformConfiguration = Configuration(newConfig)
+        appLocaleGateway.synchronizeFromPlatform()
+
+        val platformDiff = AppUiConfigurationDiff(
+            localeChanged = previous != null && previous.locales != newConfig.locales,
+            themeChanged = hasPlatformNightModeChanged(
+                themeMode = appUiConfigurationGateway.currentConfiguration.appShell.themeMode,
+                previousUiMode = previous?.uiMode,
+                newUiMode = newConfig.uiMode,
+            ),
+        )
+        if (platformDiff.hasChanges) {
+            handleAppUiConfiguration(
+                appUiConfigurationGateway.currentConfiguration,
+                platformDiff,
+            )
+        }
+        traceConfiguration("onConfigurationChanged", platformDiff)
+    }
+
+    override fun onLocalesChanged(locales: LocaleListCompat) {
+        super.onLocalesChanged(locales)
+        appLocaleGateway.synchronizeFromPlatform()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        postControlledRecreateIfNeeded()
+    }
+
+    override fun onDestroy() {
+        traceConfiguration("onDestroy")
+        super.onDestroy()
+    }
 
 
 
 
 
 
@@ -230,8 +286,15 @@ abstract class BaseActivity<VB : ViewBinding>(
     open fun upBackgroundImage() {
     open fun upBackgroundImage() {
         if (imageBg) {
         if (imageBg) {
             try {
             try {
-                ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
-                    window.decorView.background = it.toDrawable(resources)
+                val background = ThemeConfigStore.getBgImage(this, windowManager.windowSize)
+                if (background != null) {
+                    hasWindowBackgroundImage = true
+                    window.decorView.background = background.toDrawable(resources)
+                } else if (hasWindowBackgroundImage) {
+                    hasWindowBackgroundImage = false
+                    window.decorView.background = themeColor(
+                        com.google.android.material.R.attr.colorSurface
+                    ).toDrawable()
                 }
                 }
             } catch (e: OutOfMemoryError) {
             } catch (e: OutOfMemoryError) {
                 toastOnUi("背景图片太大,内存溢出")
                 toastOnUi("背景图片太大,内存溢出")
@@ -242,11 +305,99 @@ abstract class BaseActivity<VB : ViewBinding>(
     }
     }
 
 
     open fun observeLiveBus() {
     open fun observeLiveBus() {
-        observeEvent<String>(EventBus.RECREATE) {
-            recreate()
+        // Legacy feature events are registered by subclasses. App-wide UI configuration
+        // is collected separately through observeAppUiConfiguration().
+    }
+
+    protected open fun applyLegacyUiConfiguration(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ) = Unit
+
+    protected open fun rebindLegacyViewTree(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ): Boolean = false
+
+    private fun observeAppUiConfiguration() {
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                appUiConfigurationGateway.configuration.collect { configuration ->
+                    val previous = lastUiConfiguration
+                    lastUiConfiguration = configuration
+                    if (previous == null) return@collect
+                    val diff = configuration.diffFrom(previous)
+                    if (diff.hasChanges) {
+                        handleAppUiConfiguration(configuration, diff)
+                    }
+                }
+            }
         }
         }
     }
     }
 
 
+    private fun handleAppUiConfiguration(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ) {
+        traceConfiguration("apply", diff)
+        if (diff.themeChanged || diff.windowChanged) {
+            setupSystemBar()
+            upBackgroundImage()
+        }
+        if (!diff.requiresLegacyContentRefresh) return
+        when (legacyUiConfigurationPolicy) {
+            LegacyUiConfigurationPolicy.HotApply -> {
+                if (diff.fontScaleChanged) {
+                    AppContextWrapper.applyFont(this)
+                }
+                applyLegacyUiConfiguration(configuration, diff)
+            }
+            LegacyUiConfigurationPolicy.RebindViewTree -> {
+                if (diff.fontScaleChanged) {
+                    AppContextWrapper.applyFont(this)
+                }
+                if (!rebindLegacyViewTree(configuration, diff)) {
+                    requestControlledRecreate()
+                }
+            }
+            LegacyUiConfigurationPolicy.ControlledRecreate -> requestControlledRecreate()
+            LegacyUiConfigurationPolicy.ApplyOnNextOpen -> Unit
+        }
+    }
+
+    private fun requestControlledRecreate() {
+        pendingControlledRecreate = true
+        postControlledRecreateIfNeeded()
+    }
+
+    private fun postControlledRecreateIfNeeded() {
+        if (!pendingControlledRecreate || recreatePosted || isFinishing || isDestroyed) return
+        if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
+        recreatePosted = true
+        window.decorView.post {
+            recreatePosted = false
+            if (pendingControlledRecreate && !isFinishing && !isDestroyed &&
+                lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
+            ) {
+                pendingControlledRecreate = false
+                traceConfiguration("controlledRecreate")
+                recreate()
+            }
+        }
+    }
+
+    private fun traceConfiguration(
+        event: String,
+        diff: AppUiConfigurationDiff? = null,
+    ) {
+        if (!BuildConfig.DEBUG) return
+        LogUtils.d(
+            "UiConfiguration",
+            "${javaClass.simpleName}@${System.identityHashCode(this)} $event" +
+                (diff?.let { " diff=$it" } ?: "")
+        )
+    }
+
     override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
     override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
         return try {
         return try {
             super.dispatchTouchEvent(ev)
             super.dispatchTouchEvent(ev)

+ 69 - 6
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -11,21 +11,33 @@ import androidx.compose.runtime.SideEffect
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.graphics.toArgb
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowCompat
+import androidx.core.os.LocaleListCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import io.legado.app.BuildConfig
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.Theme
 import io.legado.app.constant.Theme
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.fullScreen
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.windowSize
 import io.legado.app.utils.windowSize
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.MiuixTheme
+import org.koin.android.ext.android.inject
+import kotlinx.coroutines.launch
 
 
 abstract class BaseComposeActivity(
 abstract class BaseComposeActivity(
     val fullScreen: Boolean = true,
     val fullScreen: Boolean = true,
@@ -34,6 +46,10 @@ abstract class BaseComposeActivity(
     private val imageBg: Boolean = true
     private val imageBg: Boolean = true
 ) : AppCompatActivity() {
 ) : AppCompatActivity() {
 
 
+    private val appLocaleGateway by inject<AppLocaleGateway>()
+    private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private var lastUiConfiguration: AppUiConfiguration? = null
+
     @Composable
     @Composable
     protected abstract fun Content()
     protected abstract fun Content()
 
 
@@ -42,6 +58,7 @@ abstract class BaseComposeActivity(
         AppContextWrapper.applyFont(this)
         AppContextWrapper.applyFont(this)
 
 
         super.onCreate(savedInstanceState)
         super.onCreate(savedInstanceState)
+        lastUiConfiguration = appUiConfigurationGateway.currentConfiguration
         enableEdgeToEdge()
         enableEdgeToEdge()
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
             window.isNavigationBarContrastEnforced = false
             window.isNavigationBarContrastEnforced = false
@@ -61,19 +78,28 @@ abstract class BaseComposeActivity(
         }
         }
 
 
         observeLiveBus()
         observeLiveBus()
+        observeAppUiConfiguration()
+        traceConfiguration("onCreate")
     }
     }
 
 
     override fun onConfigurationChanged(newConfig: Configuration) {
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
         super.onConfigurationChanged(newConfig)
-        // manifest 声明 locale/layoutDirection/uiMode configChanges 后,Compose 主壳
+        // manifest 声明 locale/layoutDirection/screenLayout/uiMode configChanges 后,Compose 主壳
         // 可直接响应语言与深浅色变化,无需销毁 Activity。AppCompat 手动回调本方法时
         // 可直接响应语言与深浅色变化,无需销毁 Activity。AppCompat 手动回调本方法时
         // 不会走 View 树分发,需要自己同步给 Compose(LocalConfiguration),并刷新
         // 不会走 View 树分发,需要自己同步给 Compose(LocalConfiguration),并刷新
         // 系统栏与背景图。
         // 系统栏与背景图。
         window.decorView.dispatchConfigurationChanged(newConfig)
         window.decorView.dispatchConfigurationChanged(newConfig)
+        appLocaleGateway.synchronizeFromPlatform()
         setupSystemBar()
         setupSystemBar()
         if (imageBg) {
         if (imageBg) {
             upBackgroundImage()
             upBackgroundImage()
         }
         }
+        traceConfiguration("onConfigurationChanged")
+    }
+
+    override fun onLocalesChanged(locales: LocaleListCompat) {
+        super.onLocalesChanged(locales)
+        appLocaleGateway.synchronizeFromPlatform()
     }
     }
 
 
     open fun setupSystemBar() {
     open fun setupSystemBar() {
@@ -92,9 +118,16 @@ abstract class BaseComposeActivity(
 
 
     open fun upBackgroundImage() {
     open fun upBackgroundImage() {
         try {
         try {
-            ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
+            val background = ThemeConfigStore.getBgImage(this, windowManager.windowSize)
+            if (background != null) {
                 hasWindowBgImage = true
                 hasWindowBgImage = true
-                window.setBackgroundDrawable(it.toDrawable(resources))
+                window.setBackgroundDrawable(background.toDrawable(resources))
+            } else if (hasWindowBgImage) {
+                hasWindowBgImage = false
+                lastWindowBgColor = null
+                window.setBackgroundDrawable(
+                    themeColor(com.google.android.material.R.attr.colorSurface).toDrawable()
+                )
             }
             }
         } catch (_: Exception) {}
         } catch (_: Exception) {}
     }
     }
@@ -127,12 +160,42 @@ abstract class BaseComposeActivity(
     }
     }
 
 
     open fun observeLiveBus() {
     open fun observeLiveBus() {
-        observeEvent<String>(EventBus.RECREATE) {
-            recreate()
-        }
         observeEvent<Boolean>(EventBus.NOTIFY_MAIN) {
         observeEvent<Boolean>(EventBus.NOTIFY_MAIN) {
             setupSystemBar()
             setupSystemBar()
         }
         }
     }
     }
 
 
+    private fun observeAppUiConfiguration() {
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                appUiConfigurationGateway.configuration.collect { configuration ->
+                    val previous = lastUiConfiguration
+                    lastUiConfiguration = configuration
+                    if (previous == null) return@collect
+                    val diff = configuration.diffFrom(previous)
+                    if (diff.windowChanged) {
+                        setupSystemBar()
+                        if (imageBg) upBackgroundImage()
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onDestroy() {
+        traceConfiguration(
+            "onDestroy changingConfigurations=0x${changingConfigurations.toString(16)} " +
+                "isChangingConfigurations=$isChangingConfigurations isFinishing=$isFinishing"
+        )
+        super.onDestroy()
+    }
+
+    private fun traceConfiguration(event: String) {
+        if (!BuildConfig.DEBUG) return
+        LogUtils.d(
+            "UiConfiguration",
+            "${javaClass.simpleName}@${System.identityHashCode(this)} $event",
+        )
+    }
+
 }
 }

+ 18 - 0
app/src/main/java/io/legado/app/base/LegacyUiConfiguration.kt

@@ -0,0 +1,18 @@
+package io.legado.app.base
+
+import android.content.res.Configuration
+
+enum class LegacyUiConfigurationPolicy {
+    HotApply,
+    RebindViewTree,
+    ControlledRecreate,
+    ApplyOnNextOpen,
+}
+
+internal fun hasPlatformNightModeChanged(
+    themeMode: String,
+    previousUiMode: Int?,
+    newUiMode: Int,
+): Boolean = themeMode == "0" && previousUiMode != null &&
+    (previousUiMode and Configuration.UI_MODE_NIGHT_MASK) !=
+    (newUiMode and Configuration.UI_MODE_NIGHT_MASK)

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

@@ -2,7 +2,6 @@ package io.legado.app.constant
 
 
 object EventBus {
 object EventBus {
     const val MEDIA_BUTTON = "mediaButton"
     const val MEDIA_BUTTON = "mediaButton"
-    const val RECREATE = "RECREATE"
     const val UP_BOOKSHELF = "upBookToc"
     const val UP_BOOKSHELF = "upBookToc"
     const val BOOKSHELF_REFRESH = "bookshelfRefresh"
     const val BOOKSHELF_REFRESH = "bookshelfRefresh"
     const val ALOUD_STATE = "aloud_state"
     const val ALOUD_STATE = "aloud_state"

+ 103 - 0
app/src/main/java/io/legado/app/data/repository/AppLocaleRepository.kt

@@ -0,0 +1,103 @@
+package io.legado.app.data.repository
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.os.LocaleListCompat
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.Locale
+
+internal interface AppLocalePlatform {
+    fun getApplicationLocales(): LocaleListCompat
+    fun setApplicationLocales(locales: LocaleListCompat)
+}
+
+private object AppCompatLocalePlatform : AppLocalePlatform {
+    override fun getApplicationLocales(): LocaleListCompat =
+        AppCompatDelegate.getApplicationLocales()
+
+    override fun setApplicationLocales(locales: LocaleListCompat) {
+        AppCompatDelegate.setApplicationLocales(locales)
+    }
+}
+
+class AppLocaleRepository internal constructor(
+    private val platform: AppLocalePlatform = AppCompatLocalePlatform,
+    private val persistLanguage: (String) -> Unit = {
+        AppConfigStore.putString(PreferKey.language, it)
+    },
+    readPersistedLanguage: () -> String? = {
+        AppConfigStore.getString(PreferKey.language)
+    },
+) : AppLocaleGateway {
+
+    private var mirroredLanguage = normalizeLanguage(readPersistedLanguage())
+    private val _language = MutableStateFlow(
+        languageForLocaleList(platform.getApplicationLocales())
+    )
+    override val language = _language.asStateFlow()
+    override val currentLanguage: String
+        get() = _language.value
+
+    override fun setLanguage(language: String) {
+        val normalized = normalizeLanguage(language)
+        publish(normalized)
+        val locales = localeListForLanguage(normalized)
+        if (platform.getApplicationLocales() != locales) {
+            platform.setApplicationLocales(locales)
+        }
+    }
+
+    override fun synchronizeFromPlatform() {
+        publish(languageForLocaleList(platform.getApplicationLocales()))
+    }
+
+    override fun migrateLegacyLanguage(language: String) {
+        val platformLocales = platform.getApplicationLocales()
+        if (platformLocales.isEmpty && normalizeLanguage(language) != "auto") {
+            setLanguage(language)
+        } else {
+            synchronizeFromPlatform()
+        }
+    }
+
+    private fun publish(language: String) {
+        val normalized = normalizeLanguage(language)
+        if (_language.value != normalized) {
+            _language.value = normalized
+        }
+        if (mirroredLanguage != normalized) {
+            mirroredLanguage = normalized
+            persistLanguage(normalized)
+        }
+    }
+}
+
+internal fun normalizeLanguage(language: String?): String = when (language) {
+    "zh", "tw", "en" -> language
+    else -> "auto"
+}
+
+internal fun localeListForLanguage(language: String?): LocaleListCompat =
+    when (normalizeLanguage(language)) {
+        "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
+        "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
+        "en" -> LocaleListCompat.create(Locale.ENGLISH)
+        else -> LocaleListCompat.getEmptyLocaleList()
+    }
+
+internal fun languageForLocaleList(locales: LocaleListCompat): String {
+    if (locales.isEmpty) return "auto"
+    val locale = locales[0] ?: return "auto"
+    return when {
+        locale.language == Locale.CHINESE.language &&
+            (locale.country.equals("TW", ignoreCase = true) ||
+                locale.country.equals("HK", ignoreCase = true) ||
+                locale.script.equals("Hant", ignoreCase = true)) -> "tw"
+        locale.language == Locale.CHINESE.language -> "zh"
+        locale.language == Locale.ENGLISH.language -> "en"
+        else -> "auto"
+    }
+}

+ 42 - 0
app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt

@@ -0,0 +1,42 @@
+package io.legado.app.data.repository
+
+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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+
+class AppUiConfigurationRepository(
+    private val appLocaleGateway: AppLocaleGateway,
+    private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
+) : AppUiConfigurationGateway {
+
+    private val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+    private val initialConfiguration = AppUiConfiguration(
+        language = appLocaleGateway.currentLanguage,
+        appShell = appShellSettingsGateway.currentSettings,
+        theme = themeSettingsGateway.currentSettings,
+    )
+
+    override val currentConfiguration: AppUiConfiguration
+        get() = configuration.value
+
+    override val configuration: StateFlow<AppUiConfiguration> = combine(
+        appLocaleGateway.language,
+        appShellSettingsGateway.settings,
+        themeSettingsGateway.settings,
+        ::AppUiConfiguration,
+    ).stateIn(
+        scope = processScope,
+        started = SharingStarted.Eagerly,
+        initialValue = initialConfiguration,
+    )
+}

+ 35 - 32
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -57,35 +57,40 @@ class AppShellSettingsRepository : AppShellSettingsGateway {
     override suspend fun updateAll(updates: List<AppShellSettingsUpdate>) {
     override suspend fun updateAll(updates: List<AppShellSettingsUpdate>) {
         val values = updates.associate { update ->
         val values = updates.associate { update ->
             val (key, value) = when (update) {
             val (key, value) = when (update) {
-            is AppShellSettingsUpdate.ThemeMode -> PreferKey.themeMode to update.value
-            is AppShellSettingsUpdate.Language -> PreferKey.language to update.value
-            is AppShellSettingsUpdate.FontScale -> PreferKey.fontScale to update.value
-            is AppShellSettingsUpdate.ComposeEngine -> PreferKey.composeEngine to update.value
-            is AppShellSettingsUpdate.MainNavigationOrder -> PreferKey.mainNavigationOrder to update.value
-            is AppShellSettingsUpdate.BooleanValue -> when (update.setting) {
-                AppShellBooleanSetting.ShowHome -> PreferKey.showHome to update.value
-                AppShellBooleanSetting.ShowDiscovery -> PreferKey.showDiscovery to update.value
-                AppShellBooleanSetting.ShowRss -> PreferKey.showRss to update.value
-                AppShellBooleanSetting.ShowStatusBar -> PreferKey.showStatusBar to update.value
-                AppShellBooleanSetting.SwipeAnimation -> PreferKey.swipeAnimation to update.value
-                AppShellBooleanSetting.PredictiveBack -> PreferKey.isPredictiveBackEnabled to update.value
-                AppShellBooleanSetting.ShowBottomView -> PreferKey.showBottomView to update.value
-                AppShellBooleanSetting.UseFloatingBottomBar -> PreferKey.useFloatingBottomBar to update.value
-                AppShellBooleanSetting.UseFloatingBottomBarLiquidGlass ->
-                    PreferKey.useFloatingBottomBarLiquidGlass to update.value
-                AppShellBooleanSetting.NavExtended -> PreferKey.navExtended to update.value
-            }
-            is AppShellSettingsUpdate.StringValue -> when (update.setting) {
-                AppShellStringSetting.TabletInterface -> PreferKey.tabletInterface to update.value
-                AppShellStringSetting.LabelVisibilityMode -> PreferKey.labelVisibilityMode to update.value
-                AppShellStringSetting.DefaultHomePage -> PreferKey.defaultHomePage to update.value
-                AppShellStringSetting.NavIconHome -> PreferKey.navIconHome to update.value
-                AppShellStringSetting.NavIconBookshelf -> PreferKey.navIconBookshelf to update.value
-                AppShellStringSetting.NavIconExplore -> PreferKey.navIconExplore to update.value
-                AppShellStringSetting.NavIconRss -> PreferKey.navIconRss to update.value
-                AppShellStringSetting.NavIconMy -> PreferKey.navIconMy to update.value
-                AppShellStringSetting.LauncherIcon -> PreferKey.launcherIcon to update.value
-            }
+                is AppShellSettingsUpdate.ThemeMode -> PreferKey.themeMode to update.value
+                is AppShellSettingsUpdate.FontScale -> PreferKey.fontScale to update.value
+                is AppShellSettingsUpdate.ComposeEngine -> PreferKey.composeEngine to update.value
+                is AppShellSettingsUpdate.MainNavigationOrder ->
+                    PreferKey.mainNavigationOrder to update.value
+                is AppShellSettingsUpdate.BooleanValue -> when (update.setting) {
+                    AppShellBooleanSetting.ShowHome -> PreferKey.showHome to update.value
+                    AppShellBooleanSetting.ShowDiscovery -> PreferKey.showDiscovery to update.value
+                    AppShellBooleanSetting.ShowRss -> PreferKey.showRss to update.value
+                    AppShellBooleanSetting.ShowStatusBar -> PreferKey.showStatusBar to update.value
+                    AppShellBooleanSetting.SwipeAnimation -> PreferKey.swipeAnimation to update.value
+                    AppShellBooleanSetting.PredictiveBack ->
+                        PreferKey.isPredictiveBackEnabled to update.value
+                    AppShellBooleanSetting.ShowBottomView -> PreferKey.showBottomView to update.value
+                    AppShellBooleanSetting.UseFloatingBottomBar ->
+                        PreferKey.useFloatingBottomBar to update.value
+                    AppShellBooleanSetting.UseFloatingBottomBarLiquidGlass ->
+                        PreferKey.useFloatingBottomBarLiquidGlass to update.value
+                    AppShellBooleanSetting.NavExtended -> PreferKey.navExtended to update.value
+                }
+                is AppShellSettingsUpdate.StringValue -> when (update.setting) {
+                    AppShellStringSetting.TabletInterface ->
+                        PreferKey.tabletInterface to update.value
+                    AppShellStringSetting.LabelVisibilityMode ->
+                        PreferKey.labelVisibilityMode to update.value
+                    AppShellStringSetting.DefaultHomePage ->
+                        PreferKey.defaultHomePage to update.value
+                    AppShellStringSetting.NavIconHome -> PreferKey.navIconHome to update.value
+                    AppShellStringSetting.NavIconBookshelf -> PreferKey.navIconBookshelf to update.value
+                    AppShellStringSetting.NavIconExplore -> PreferKey.navIconExplore to update.value
+                    AppShellStringSetting.NavIconRss -> PreferKey.navIconRss to update.value
+                    AppShellStringSetting.NavIconMy -> PreferKey.navIconMy to update.value
+                    AppShellStringSetting.LauncherIcon -> PreferKey.launcherIcon to update.value
+                }
             }
             }
             key to value
             key to value
         }
         }
@@ -364,7 +369,6 @@ class BackupSettingsRepository : BackupSettingsGateway {
 
 
 private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
 private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
     themeMode = compatDsString(PreferKey.themeMode) ?: "0",
     themeMode = compatDsString(PreferKey.themeMode) ?: "0",
-    language = compatDsString(PreferKey.language) ?: "auto",
     fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
     fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
     composeEngine = compatDsString(PreferKey.composeEngine) ?: "material",
     composeEngine = compatDsString(PreferKey.composeEngine) ?: "material",
     showHome = compatDsBoolean(PreferKey.showHome) ?: true,
     showHome = compatDsBoolean(PreferKey.showHome) ?: true,
@@ -398,6 +402,7 @@ private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     paletteStyle = compatDsString(PreferKey.paletteStyle) ?: "tonalSpot",
     paletteStyle = compatDsString(PreferKey.paletteStyle) ?: "tonalSpot",
     materialVersion = compatDsString(PreferKey.materialVersion) ?: "material3",
     materialVersion = compatDsString(PreferKey.materialVersion) ?: "material3",
     customContrast = compatDsString(PreferKey.customContrast) ?: "Default",
     customContrast = compatDsString(PreferKey.customContrast) ?: "Default",
+    customMode = compatDsString(PreferKey.customMode) ?: "tonalSpot",
     appFontPath = compatDsString(PreferKey.appFontPath),
     appFontPath = compatDsString(PreferKey.appFontPath),
     customPrimary = compatDsInt(PreferKey.cPrimary) ?: 0,
     customPrimary = compatDsInt(PreferKey.cPrimary) ?: 0,
     customNightPrimary = compatDsInt(PreferKey.cNPrimary) ?: 0,
     customNightPrimary = compatDsInt(PreferKey.cNPrimary) ?: 0,
@@ -455,7 +460,6 @@ class OtherSettingsRepository : OtherSettingsGateway {
         .map { preferences ->
         .map { preferences ->
             val rawSourceEditMaxLine = preferences.compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
             val rawSourceEditMaxLine = preferences.compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
             OtherSettings(
             OtherSettings(
-                language = preferences.compatDsString(PreferKey.language) ?: "auto",
                 updateToVariant = preferences.compatDsString(PreferKey.updateToVariant) ?: "official_version",
                 updateToVariant = preferences.compatDsString(PreferKey.updateToVariant) ?: "official_version",
                 autoCheckUpdateOnStart =
                 autoCheckUpdateOnStart =
                     preferences.compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
                     preferences.compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
@@ -484,7 +488,6 @@ class OtherSettingsRepository : OtherSettingsGateway {
 
 
     override suspend fun update(update: OtherSettingsUpdate) {
     override suspend fun update(update: OtherSettingsUpdate) {
         val (key, value) = when (update) {
         val (key, value) = when (update) {
-            is OtherSettingsUpdate.Language -> PreferKey.language to update.value
             is OtherSettingsUpdate.UpdateToVariant -> PreferKey.updateToVariant to update.value
             is OtherSettingsUpdate.UpdateToVariant -> PreferKey.updateToVariant to update.value
             is OtherSettingsUpdate.AutoCheckUpdateOnStart -> PreferKey.autoCheckUpdateOnStart to update.value
             is OtherSettingsUpdate.AutoCheckUpdateOnStart -> PreferKey.autoCheckUpdateOnStart to update.value
             is OtherSettingsUpdate.WebServiceAutoStart -> PreferKey.webServiceAutoStart to update.value
             is OtherSettingsUpdate.WebServiceAutoStart -> PreferKey.webServiceAutoStart to update.value

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

@@ -14,7 +14,9 @@ import io.legado.app.data.repository.AiPromptPresetRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.AppStartupRepository
 import io.legado.app.data.repository.AppStartupRepository
+import io.legado.app.data.repository.AppLocaleRepository
 import io.legado.app.data.repository.AppShellSettingsRepository
 import io.legado.app.data.repository.AppShellSettingsRepository
+import io.legado.app.data.repository.AppUiConfigurationRepository
 import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BackupSettingsRepository
 import io.legado.app.data.repository.BackupSettingsRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
@@ -71,7 +73,9 @@ import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.AppStartupGateway
 import io.legado.app.domain.gateway.AppStartupGateway
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
 import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
@@ -235,8 +239,12 @@ val appModule = module {
     }
     }
     singleOf(::RemoteBookRepository)
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
     singleOf(::SettingsRepository)
+    single<AppLocaleGateway> { AppLocaleRepository() }
     single<AppShellSettingsGateway> { AppShellSettingsRepository() }
     single<AppShellSettingsGateway> { AppShellSettingsRepository() }
     single<ThemeSettingsGateway> { ThemeSettingsRepository() }
     single<ThemeSettingsGateway> { ThemeSettingsRepository() }
+    single<AppUiConfigurationGateway> {
+        AppUiConfigurationRepository(get(), get(), get())
+    }
     single<OtherSettingsGateway> { OtherSettingsRepository() }
     single<OtherSettingsGateway> { OtherSettingsRepository() }
     single<DownloadCacheSettingsGateway> { DownloadCacheSettingsRepository() }
     single<DownloadCacheSettingsGateway> { DownloadCacheSettingsRepository() }
     single<CoverSettingsGateway> { CoverSettingsRepository() }
     single<CoverSettingsGateway> { CoverSettingsRepository() }
@@ -366,7 +374,7 @@ val appModule = module {
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
     viewModelOf(::TxtTocRuleViewModel)
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
-    viewModel { OtherConfigViewModel(get(), get()) }
+    viewModel { OtherConfigViewModel(get(), get(), get()) }
     viewModelOf(::CustomThemeViewModel)
     viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)
     viewModelOf(::CoverConfigViewModel)

+ 14 - 0
app/src/main/java/io/legado/app/domain/gateway/AppLocaleGateway.kt

@@ -0,0 +1,14 @@
+package io.legado.app.domain.gateway
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface AppLocaleGateway {
+    val currentLanguage: String
+    val language: StateFlow<String>
+
+    fun setLanguage(language: String)
+
+    fun synchronizeFromPlatform()
+
+    fun migrateLegacyLanguage(language: String)
+}

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

@@ -12,7 +12,6 @@ interface AppShellSettingsGateway {
 
 
 sealed interface AppShellSettingsUpdate {
 sealed interface AppShellSettingsUpdate {
     data class ThemeMode(val value: String) : AppShellSettingsUpdate
     data class ThemeMode(val value: String) : AppShellSettingsUpdate
-    data class Language(val value: String) : AppShellSettingsUpdate
     data class FontScale(val value: Int) : AppShellSettingsUpdate
     data class FontScale(val value: Int) : AppShellSettingsUpdate
     data class ComposeEngine(val value: String) : AppShellSettingsUpdate
     data class ComposeEngine(val value: String) : AppShellSettingsUpdate
     data class MainNavigationOrder(val value: String) : AppShellSettingsUpdate
     data class MainNavigationOrder(val value: String) : AppShellSettingsUpdate

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

@@ -0,0 +1,9 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import kotlinx.coroutines.flow.StateFlow
+
+interface AppUiConfigurationGateway {
+    val currentConfiguration: AppUiConfiguration
+    val configuration: StateFlow<AppUiConfiguration>
+}

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

@@ -9,7 +9,6 @@ interface OtherSettingsGateway {
 }
 }
 
 
 sealed interface OtherSettingsUpdate {
 sealed interface OtherSettingsUpdate {
-    data class Language(val value: String) : OtherSettingsUpdate
     data class UpdateToVariant(val value: String) : OtherSettingsUpdate
     data class UpdateToVariant(val value: String) : OtherSettingsUpdate
     data class AutoCheckUpdateOnStart(val value: Boolean) : OtherSettingsUpdate
     data class AutoCheckUpdateOnStart(val value: Boolean) : OtherSettingsUpdate
     data class WebServiceAutoStart(val value: Boolean) : OtherSettingsUpdate
     data class WebServiceAutoStart(val value: Boolean) : OtherSettingsUpdate

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

@@ -2,7 +2,6 @@ package io.legado.app.domain.model.settings
 
 
 data class AppShellSettings(
 data class AppShellSettings(
     val themeMode: String = "0",
     val themeMode: String = "0",
-    val language: String = "auto",
     val fontScale: Int = 10,
     val fontScale: Int = 10,
     val composeEngine: String = "material",
     val composeEngine: String = "material",
     val showHome: Boolean = true,
     val showHome: Boolean = true,

+ 41 - 0
app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt

@@ -0,0 +1,41 @@
+package io.legado.app.domain.model.settings
+
+data class AppUiConfiguration(
+    val language: String = "auto",
+    val appShell: AppShellSettings = AppShellSettings(),
+    val theme: ThemeSettings = ThemeSettings(),
+)
+
+data class AppUiConfigurationDiff(
+    val localeChanged: Boolean = false,
+    val themeChanged: Boolean = false,
+    val fontScaleChanged: Boolean = false,
+    val windowChanged: Boolean = false,
+) {
+    val hasChanges: Boolean
+        get() = localeChanged || themeChanged || fontScaleChanged || windowChanged
+
+    val requiresLegacyContentRefresh: Boolean
+        get() = localeChanged || themeChanged || fontScaleChanged
+}
+
+fun AppUiConfiguration.diffFrom(previous: AppUiConfiguration): AppUiConfigurationDiff =
+    AppUiConfigurationDiff(
+        localeChanged = language != previous.language,
+        themeChanged = appShell.themeMode != previous.appShell.themeMode ||
+            theme.requiresLegacyThemeRefresh(previous.theme),
+        fontScaleChanged = appShell.fontScale != previous.appShell.fontScale,
+        windowChanged = appShell.showStatusBar != previous.appShell.showStatusBar ||
+            theme.backgroundImageLight != previous.theme.backgroundImageLight ||
+            theme.backgroundImageDark != previous.theme.backgroundImageDark ||
+            theme.backgroundImageBlurring != previous.theme.backgroundImageBlurring ||
+            theme.backgroundImageDarkBlurring != previous.theme.backgroundImageDarkBlurring,
+    )
+
+private fun ThemeSettings.requiresLegacyThemeRefresh(previous: ThemeSettings): Boolean =
+    appTheme != previous.appTheme ||
+        isPureBlack != previous.isPureBlack ||
+        customMode != previous.customMode ||
+        appFontPath != previous.appFontPath ||
+        customPrimary != previous.customPrimary ||
+        customNightPrimary != previous.customNightPrimary

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

@@ -1,7 +1,6 @@
 package io.legado.app.domain.model.settings
 package io.legado.app.domain.model.settings
 
 
 data class OtherSettings(
 data class OtherSettings(
-    val language: String = "auto",
     val updateToVariant: String = "official_version",
     val updateToVariant: String = "official_version",
     val autoCheckUpdateOnStart: Boolean = false,
     val autoCheckUpdateOnStart: Boolean = false,
     val webServiceAutoStart: Boolean = false,
     val webServiceAutoStart: Boolean = false,

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

@@ -7,6 +7,7 @@ data class ThemeSettings(
     val paletteStyle: String = "tonalSpot",
     val paletteStyle: String = "tonalSpot",
     val materialVersion: String = "material3",
     val materialVersion: String = "material3",
     val customContrast: String = "Default",
     val customContrast: String = "Default",
+    val customMode: String = "tonalSpot",
     val appFontPath: String? = null,
     val appFontPath: String? = null,
     val customPrimary: Int = 0,
     val customPrimary: Int = 0,
     val customNightPrimary: Int = 0,
     val customNightPrimary: Int = 0,

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

@@ -45,14 +45,12 @@ object ThemeConfigStore {
 
 
     fun applyDayNight(context: Context) {
     fun applyDayNight(context: Context) {
         initNightMode()
         initNightMode()
-        postEvent(EventBus.RECREATE, "")
         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
     }
     }
 
 
     /**
     /**
-     * 切换深浅色但不发 RECREATE:Compose 界面通过 ThemeConfig.themeMode 快照状态
-     * 自动换色(保留 ToggleButton 等组件的过渡动画),setDefaultNightMode 只会重建
-     * 未在 manifest 声明 uiMode configChanges 的旧 View Activity
+     * Compose 界面通过 ThemeConfig.themeMode 快照状态自动换色;旧 View 界面由
+     * BaseActivity 的兼容策略决定热更新、重新绑定或受控重建。
      */
      */
     fun applyDayNightLive() {
     fun applyDayNightLive() {
         initNightMode()
         initNightMode()

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

@@ -3,12 +3,12 @@ package io.legado.app.help.storage
 import android.content.Context
 import android.content.Context
 import android.database.sqlite.SQLiteConstraintException
 import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
 import android.net.Uri
-import androidx.appcompat.app.AppCompatDelegate
 import androidx.documentfile.provider.DocumentFile
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.BuildConfig
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookGroup
@@ -42,7 +42,6 @@ import io.legado.app.help.config.SettingsWriter
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.config.otherConfig.appLocaleListFor
 import io.legado.app.model.BookCover
 import io.legado.app.model.BookCover
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.utils.ACache
 import io.legado.app.utils.ACache
@@ -60,6 +59,7 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
 import org.xmlpull.v1.XmlPullParser
 import org.xmlpull.v1.XmlPullParser
 import org.xmlpull.v1.XmlPullParserFactory
 import org.xmlpull.v1.XmlPullParserFactory
 import splitties.init.appCtx
 import splitties.init.appCtx
@@ -335,7 +335,7 @@ object Restore : KoinComponent {
         appCtx.toastOnUi(R.string.restore_success)
         appCtx.toastOnUi(R.string.restore_success)
         withContext(Main) {
         withContext(Main) {
             delay(100)
             delay(100)
-            AppCompatDelegate.setApplicationLocales(appLocaleListFor(OtherConfig.language))
+            get<AppLocaleGateway>().setLanguage(OtherConfig.language)
             if (!BuildConfig.DEBUG) {
             if (!BuildConfig.DEBUG) {
                 LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
                 LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
             }
             }

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

@@ -16,11 +16,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.card.MaterialCardView
 import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.getPrefString
-import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
@@ -99,7 +97,6 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                     callChangeListener(value)
                     callChangeListener(value)
                     ThemeConfig.appTheme = value
                     ThemeConfig.appTheme = value
                     notifyDataSetChanged()
                     notifyDataSetChanged()
-                    postEvent(EventBus.RECREATE, "")
                 }
                 }
             }
             }
         }
         }
@@ -157,4 +154,3 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
         val background : MaterialCardView = view.findViewById(R.id.cardView)
         val background : MaterialCardView = view.findViewById(R.id.cardView)
     }
     }
 }
 }
-

+ 0 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -775,7 +775,6 @@ sealed interface ReadBookEffect {
 
 
     // Navigation / lifecycle
     // Navigation / lifecycle
     data object Finish : ReadBookEffect
     data object Finish : ReadBookEffect
-    data object Recreate : ReadBookEffect
 
 
     // ReadView operations (require Activity/View reference)
     // ReadView operations (require Activity/View reference)
     data class UpdateReadViewConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect
     data class UpdateReadViewConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect

+ 0 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -805,7 +805,6 @@ class ReadBookController(
         when (effect) {
         when (effect) {
             // ── Already migrated (View-layer) ──
             // ── Already migrated (View-layer) ──
             is ReadBookEffect.Finish -> closeReadBook()
             is ReadBookEffect.Finish -> closeReadBook()
-            is ReadBookEffect.Recreate -> activity.recreate()
             is ReadBookEffect.UpdateReadViewConfig -> {
             is ReadBookEffect.UpdateReadViewConfig -> {
                 val r = refs ?: return
                 val r = refs ?: return
                 effect.actions.forEach { action ->
                 effect.actions.forEach { action ->

+ 1 - 11
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt

@@ -1,22 +1,12 @@
 package io.legado.app.ui.config.otherConfig
 package io.legado.app.ui.config.otherConfig
 
 
-import androidx.core.os.LocaleListCompat
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfig
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfig
 import io.legado.app.ui.config.prefDelegate
 import io.legado.app.ui.config.prefDelegate
-import java.util.Locale
-
-/** 语言偏好值 → per-app locale 列表;"auto" 及未知值返回空列表(跟随系统) */
-fun appLocaleListFor(language: String?): LocaleListCompat = when (language) {
-    "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
-    "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
-    "en" -> LocaleListCompat.create(Locale.ENGLISH)
-    else -> LocaleListCompat.getEmptyLocaleList()
-}
 
 
 object OtherConfig {
 object OtherConfig {
 
 
-    var language by prefDelegate(
+    val language by prefDelegate(
         PreferKey.language,
         PreferKey.language,
         "auto"
         "auto"
     )
     )

+ 0 - 1
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt

@@ -100,7 +100,6 @@ sealed interface OtherConfigIntent {
 }
 }
 
 
 sealed interface OtherConfigEffect {
 sealed interface OtherConfigEffect {
-    data class ApplyLanguage(val language: String) : OtherConfigEffect
     data object RequestNotificationPermission : OtherConfigEffect
     data object RequestNotificationPermission : OtherConfigEffect
     data object RequestBatteryPermission : OtherConfigEffect
     data object RequestBatteryPermission : OtherConfigEffect
     data object OpenSystemDirectory : OtherConfigEffect
     data object OpenSystemDirectory : OtherConfigEffect

+ 0 - 4
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt

@@ -6,7 +6,6 @@ import android.os.Build
 import android.widget.Toast
 import android.widget.Toast
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatDelegate
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -53,9 +52,6 @@ fun OtherConfigRouteScreen(
     LaunchedEffect(viewModel, context) {
     LaunchedEffect(viewModel, context) {
         viewModel.effects.collectLatest { effect ->
         viewModel.effects.collectLatest { effect ->
             when (effect) {
             when (effect) {
-                is OtherConfigEffect.ApplyLanguage -> {
-                    AppCompatDelegate.setApplicationLocales(appLocaleListFor(effect.language))
-                }
                 OtherConfigEffect.RequestNotificationPermission -> {
                 OtherConfigEffect.RequestNotificationPermission -> {
                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                         notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
                         notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)

+ 8 - 5
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -35,6 +36,7 @@ import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
 class OtherConfigViewModel(
 class OtherConfigViewModel(
+    private val appLocaleGateway: AppLocaleGateway,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     initialState: OtherConfigUiState = defaultOtherConfigUiState(),
     initialState: OtherConfigUiState = defaultOtherConfigUiState(),
@@ -60,6 +62,11 @@ class OtherConfigViewModel(
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
     init {
     init {
+        viewModelScope.launch {
+            appLocaleGateway.language.collect { language ->
+                _uiState.update { it.copy(language = language) }
+            }
+        }
         viewModelScope.launch(Dispatchers.IO) {
         viewModelScope.launch(Dispatchers.IO) {
             updateOtherSetting(OtherSettingsUpdate.ProcessText(isProcessTextEnabled()))
             updateOtherSetting(OtherSettingsUpdate.ProcessText(isProcessTextEnabled()))
         }
         }
@@ -83,10 +90,7 @@ class OtherConfigViewModel(
 
 
     fun onIntent(intent: OtherConfigIntent) {
     fun onIntent(intent: OtherConfigIntent) {
         when (intent) {
         when (intent) {
-            is OtherConfigIntent.LanguageChanged -> {
-                updateOtherSetting(OtherSettingsUpdate.Language(intent.value))
-                _effects.tryEmit(OtherConfigEffect.ApplyLanguage(intent.value))
-            }
+            is OtherConfigIntent.LanguageChanged -> appLocaleGateway.setLanguage(intent.value)
             is OtherConfigIntent.UpdateToVariantChanged ->
             is OtherConfigIntent.UpdateToVariantChanged ->
                 updateOtherSetting(OtherSettingsUpdate.UpdateToVariant(intent.value))
                 updateOtherSetting(OtherSettingsUpdate.UpdateToVariant(intent.value))
             is OtherConfigIntent.AutoCheckUpdateOnStartChanged ->
             is OtherConfigIntent.AutoCheckUpdateOnStartChanged ->
@@ -389,7 +393,6 @@ internal fun defaultOtherConfigUiState() = OtherConfigUiState(
 
 
 private fun OtherSettings.toUiState(current: OtherConfigUiState): OtherConfigUiState =
 private fun OtherSettings.toUiState(current: OtherConfigUiState): OtherConfigUiState =
     current.copy(
     current.copy(
-        language = language,
         updateToVariant = updateToVariant,
         updateToVariant = updateToVariant,
         autoCheckUpdateOnStart = autoCheckUpdateOnStart,
         autoCheckUpdateOnStart = autoCheckUpdateOnStart,
         webServiceAutoStart = webServiceAutoStart,
         webServiceAutoStart = webServiceAutoStart,

+ 3 - 8
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.config.themeConfig
 import android.os.Handler
 import android.os.Handler
 import android.os.Looper
 import android.os.Looper
 import androidx.appcompat.app.AppCompatDelegate
 import androidx.appcompat.app.AppCompatDelegate
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.prefDelegate
 import io.legado.app.ui.config.prefDelegate
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
@@ -103,19 +102,15 @@ object ThemeConfig {
 
 
     var isPureBlack by prefDelegate(PreferKey.pureBlack, false)
     var isPureBlack by prefDelegate(PreferKey.pureBlack, false)
 
 
-    var bgImageLight by prefDelegate<String?>(PreferKey.bgImage, null) {
-        postEvent(EventBus.RECREATE, false)
-    }
+    var bgImageLight by prefDelegate<String?>(PreferKey.bgImage, null)
 
 
-    var bgImageDark by prefDelegate<String?>(PreferKey.bgImageN, null) {
-        postEvent(EventBus.RECREATE, false)
-    }
+    var bgImageDark by prefDelegate<String?>(PreferKey.bgImageN, null)
 
 
     var bgImageBlurring by prefDelegate(PreferKey.bgImageBlurring, 0)
     var bgImageBlurring by prefDelegate(PreferKey.bgImageBlurring, 0)
 
 
     var bgImageNBlurring by prefDelegate(PreferKey.bgImageNBlurring, 0)
     var bgImageNBlurring by prefDelegate(PreferKey.bgImageNBlurring, 0)
 
 
-    // 无需 RECREATE:Compose 消费点(BackHandler enabled / predictivePopTransitionSpec)
+    // Compose 消费点(BackHandler enabled / predictivePopTransitionSpec)直接响应状态。
     // 读的是 prefDelegate 的 Compose state,切换即时生效;View 系 Activity 在下次
     // 读的是 prefDelegate 的 Compose state,切换即时生效;View 系 Activity 在下次
     // onCreate 时按新值注册 OnBackInvokedCallback(与旧版行为一致)
     // onCreate 时按新值注册 OnBackInvokedCallback(与旧版行为一致)
     var isPredictiveBackEnabled by prefDelegate(PreferKey.isPredictiveBackEnabled, true)
     var isPredictiveBackEnabled by prefDelegate(PreferKey.isPredictiveBackEnabled, true)

+ 1 - 2
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt

@@ -125,8 +125,7 @@ class ThemeManageViewModel(
             _uiState.update { it.copy(loading = true) }
             _uiState.update { it.copy(loading = true) }
             val result = themePackageManager.applySavedTheme(theme)
             val result = themePackageManager.applySavedTheme(theme)
             if (result.isSuccess) {
             if (result.isSuccess) {
-                // 主题键均为 Compose 响应式读取,写入即生效;fontScale/自定义色等
-                // 需要重建的键由各自 prefDelegate 的 RECREATE 回调按需触发
+                // Compose 由响应式设置直接更新;旧 View 由 BaseActivity 的配置兼容层处理。
                 refreshSavedThemes()
                 refreshSavedThemes()
             } else {
             } else {
                 _uiState.update { it.copy(loading = false) }
                 _uiState.update { it.copy(loading = false) }

+ 42 - 0
app/src/test/java/io/legado/app/base/LegacyUiConfigurationTest.kt

@@ -0,0 +1,42 @@
+package io.legado.app.base
+
+import android.content.res.Configuration
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LegacyUiConfigurationTest {
+
+    @Test
+    fun followSystem_detectsPlatformNightModeChange() {
+        assertTrue(
+            hasPlatformNightModeChanged(
+                themeMode = "0",
+                previousUiMode = Configuration.UI_MODE_NIGHT_YES,
+                newUiMode = Configuration.UI_MODE_NIGHT_NO,
+            )
+        )
+    }
+
+    @Test
+    fun fixedDark_ignoresPlatformNightModeChange() {
+        assertFalse(
+            hasPlatformNightModeChanged(
+                themeMode = "2",
+                previousUiMode = Configuration.UI_MODE_NIGHT_YES,
+                newUiMode = Configuration.UI_MODE_NIGHT_NO,
+            )
+        )
+    }
+
+    @Test
+    fun missingPreviousConfiguration_isNotAChange() {
+        assertFalse(
+            hasPlatformNightModeChanged(
+                themeMode = "0",
+                previousUiMode = null,
+                newUiMode = Configuration.UI_MODE_NIGHT_NO,
+            )
+        )
+    }
+}

+ 99 - 0
app/src/test/java/io/legado/app/data/repository/AppLocaleRepositoryTest.kt

@@ -0,0 +1,99 @@
+package io.legado.app.data.repository
+
+import androidx.core.os.LocaleListCompat
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class AppLocaleRepositoryTest {
+
+    @Test
+    fun setLanguage_updatesPlatformStateAndMirror() {
+        val platform = FakeAppLocalePlatform()
+        val persisted = mutableListOf<String>()
+        val repository = AppLocaleRepository(
+            platform,
+            persisted::add,
+            { persisted.lastOrNull() },
+        )
+
+        repository.setLanguage("en")
+
+        assertEquals("en", repository.currentLanguage)
+        assertEquals("en", languageForLocaleList(platform.locales))
+        assertEquals("en", persisted.last())
+    }
+
+    @Test
+    fun synchronizeFromPlatform_updatesExternalLanguageSelection() {
+        val platform = FakeAppLocalePlatform()
+        val persisted = mutableListOf<String>()
+        val repository = AppLocaleRepository(
+            platform,
+            persisted::add,
+            { persisted.lastOrNull() },
+        )
+        platform.locales = localeListForLanguage("tw")
+
+        repository.synchronizeFromPlatform()
+
+        assertEquals("tw", repository.currentLanguage)
+        assertEquals("tw", persisted.last())
+    }
+
+    @Test
+    fun synchronizeFromPlatform_keepsExternalResetToAutoMeaningful() {
+        val platform = FakeAppLocalePlatform(localeListForLanguage("zh"))
+        val persisted = mutableListOf<String>()
+        val repository = AppLocaleRepository(
+            platform,
+            persisted::add,
+            { "zh" },
+        )
+        platform.locales = LocaleListCompat.getEmptyLocaleList()
+
+        repository.synchronizeFromPlatform()
+
+        assertEquals("auto", repository.currentLanguage)
+        assertEquals("auto", persisted.last())
+    }
+
+    @Test
+    fun migration_doesNotOverwriteExistingPlatformLocale() {
+        val platform = FakeAppLocalePlatform(localeListForLanguage("zh"))
+        val repository = AppLocaleRepository(platform, { }, { "en" })
+
+        repository.migrateLegacyLanguage("en")
+
+        assertEquals("zh", repository.currentLanguage)
+        assertEquals("zh", languageForLocaleList(platform.locales))
+    }
+
+    @Test
+    fun synchronizeUnchangedLanguage_doesNotWriteMirrorAgain() {
+        val persisted = mutableListOf<String>()
+        val repository = AppLocaleRepository(
+            FakeAppLocalePlatform(),
+            persisted::add,
+            { "auto" },
+        )
+
+        repository.synchronizeFromPlatform()
+
+        assertEquals(emptyList<String>(), persisted)
+    }
+
+    private class FakeAppLocalePlatform(
+        var locales: LocaleListCompat = LocaleListCompat.getEmptyLocaleList(),
+    ) : AppLocalePlatform {
+        override fun getApplicationLocales(): LocaleListCompat = locales
+
+        override fun setApplicationLocales(locales: LocaleListCompat) {
+            this.locales = locales
+        }
+    }
+}

+ 51 - 0
app/src/test/java/io/legado/app/domain/model/settings/AppUiConfigurationTest.kt

@@ -0,0 +1,51 @@
+package io.legado.app.domain.model.settings
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AppUiConfigurationTest {
+
+    @Test
+    fun diff_reportsOnlyChangedConfigurationSlices() {
+        val previous = AppUiConfiguration()
+        val current = previous.copy(
+            language = "en",
+            appShell = previous.appShell.copy(fontScale = 12),
+        )
+
+        val diff = current.diffFrom(previous)
+
+        assertTrue(diff.localeChanged)
+        assertTrue(diff.fontScaleChanged)
+        assertFalse(diff.themeChanged)
+        assertFalse(diff.windowChanged)
+    }
+
+    @Test
+    fun diff_reportsThemeBackgroundAsWindowOnlyChange() {
+        val previous = AppUiConfiguration()
+        val current = previous.copy(
+            theme = previous.theme.copy(backgroundImageLight = "/tmp/background.png")
+        )
+
+        val diff = current.diffFrom(previous)
+
+        assertFalse(diff.themeChanged)
+        assertTrue(diff.windowChanged)
+        assertFalse(diff.requiresLegacyContentRefresh)
+    }
+
+    @Test
+    fun diff_ignoresComposeOnlyThemeChangesForLegacyContent() {
+        val previous = AppUiConfiguration()
+        val current = previous.copy(
+            appShell = previous.appShell.copy(composeEngine = "miuix"),
+            theme = previous.theme.copy(enableItemDivider = true),
+        )
+
+        val diff = current.diffFrom(previous)
+
+        assertFalse(diff.hasChanges)
+    }
+}

+ 23 - 15
app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

@@ -2,20 +2,18 @@ package io.legado.app.ui.config.otherConfig
 
 
 import android.app.Application
 import android.app.Application
 import android.os.Looper
 import android.os.Looper
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.domain.model.settings.ReadAloudSettings
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.async
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
@@ -35,27 +33,40 @@ class OtherConfigViewModelTest {
     }
     }
 
 
     @Test
     @Test
-    fun languageChanged_updatesGatewayAndEmitsApplyLanguage() = runBlocking {
+    fun languageChanged_updatesLocaleGatewayAndUiState() = runBlocking {
         val otherSettingsGateway = FakeOtherSettingsGateway()
         val otherSettingsGateway = FakeOtherSettingsGateway()
+        val appLocaleGateway = FakeAppLocaleGateway()
         val viewModel = OtherConfigViewModel(
         val viewModel = OtherConfigViewModel(
+            appLocaleGateway = appLocaleGateway,
             readAloudSettingsGateway = FakeReadAloudSettingsGateway(),
             readAloudSettingsGateway = FakeReadAloudSettingsGateway(),
             otherSettingsGateway = otherSettingsGateway,
             otherSettingsGateway = otherSettingsGateway,
             initialState = OtherConfigUiState(),
             initialState = OtherConfigUiState(),
         )
         )
-        val effect = async(start = CoroutineStart.UNDISPATCHED) {
-            viewModel.effects.first()
-        }
 
 
         viewModel.onIntent(OtherConfigIntent.LanguageChanged("en"))
         viewModel.onIntent(OtherConfigIntent.LanguageChanged("en"))
         Shadows.shadowOf(Looper.getMainLooper()).idle()
         Shadows.shadowOf(Looper.getMainLooper()).idle()
 
 
-        assertEquals(OtherConfigEffect.ApplyLanguage("en"), effect.await())
-        assertTrue(
-            otherSettingsGateway.updates.contains(OtherSettingsUpdate.Language("en"))
-        )
+        assertEquals("en", appLocaleGateway.currentLanguage)
         assertEquals("en", viewModel.uiState.value.language)
         assertEquals("en", viewModel.uiState.value.language)
     }
     }
 
 
+    private class FakeAppLocaleGateway : AppLocaleGateway {
+        private val state = MutableStateFlow("auto")
+        override val currentLanguage: String
+            get() = state.value
+        override val language = state.asStateFlow()
+
+        override fun setLanguage(language: String) {
+            state.value = language
+        }
+
+        override fun synchronizeFromPlatform() = Unit
+
+        override fun migrateLegacyLanguage(language: String) {
+            setLanguage(language)
+        }
+    }
+
     private class FakeOtherSettingsGateway : OtherSettingsGateway {
     private class FakeOtherSettingsGateway : OtherSettingsGateway {
         private val state = MutableStateFlow(OtherSettings())
         private val state = MutableStateFlow(OtherSettings())
         val updates = mutableListOf<OtherSettingsUpdate>()
         val updates = mutableListOf<OtherSettingsUpdate>()
@@ -64,9 +75,6 @@ class OtherConfigViewModelTest {
 
 
         override suspend fun update(update: OtherSettingsUpdate) {
         override suspend fun update(update: OtherSettingsUpdate) {
             updates += update
             updates += update
-            if (update is OtherSettingsUpdate.Language) {
-                state.value = state.value.copy(language = update.value)
-            }
         }
         }
     }
     }