Răsfoiți Sursa

feat: 优化应用程序的本地化支持,添加语言设置和异常处理

5151561 3 luni în urmă
părinte
comite
8558557ce4

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

@@ -1,5 +1,9 @@
 package io.legado.app
 
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.os.LocaleListCompat
+import io.legado.app.ui.config.otherConfig.OtherConfig
+import java.util.Locale
 import android.app.Application
 import android.app.Notification
 import android.app.NotificationChannel
@@ -90,6 +94,21 @@ class App : Application(), ImageLoaderFactory {
         // 首行初始化设置快照层:同步预加载 DataStore(触发 SP 迁移),
         // 之后所有 getPref* 门面读取均为纯内存查找,须先于一切主题/配置读取
         AppConfigStore.init(this)
+        val storedLanguage = OtherConfig.language
+        if (storedLanguage != "auto") {
+            val appCompatLocales = AppCompatDelegate.getApplicationLocales()
+            if (appCompatLocales.isEmpty) {
+                val localeList = when (storedLanguage) {
+                    "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
+                    "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
+                    "en" -> LocaleListCompat.create(Locale.ENGLISH)
+                    else -> null
+                }
+                if (localeList != null) {
+                    AppCompatDelegate.setApplicationLocales(localeList)
+                }
+            }
+        }
         startKoin {
             androidContext(this@App)
             modules(appDatabaseModule, appModule)

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

@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -66,6 +67,7 @@ sealed class WebDavState {
     ) : WebDavState()
 }
 
+@OptIn(kotlinx.coroutines.FlowPreview::class)
 class WebDavManager(
     private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
 ) {
@@ -85,7 +87,7 @@ class WebDavManager(
                 AppConfigStore.observeString(PreferKey.webDavPassword).drop(1),
                 AppConfigStore.observeString(PreferKey.webDavDir).drop(1),
                 AppConfigStore.observeString(PreferKey.webDavDeviceName).drop(1),
-            ).collect {
+            ).debounce(100).collect {
                 configFlow.value = readConfig()
                 refresh()
             }

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

@@ -132,9 +132,9 @@ internal class PendingOverlayCore(
             rebuild()
             // 在锁内提交写任务,保证落盘顺序与 pending 覆盖顺序一致
             launchWrite {
-                val result = persist(key, value)
+                val result = runCatching { persist(key, value) }
                 synchronized(lock) {
-                    snapshot = result
+                    result.onSuccess { snapshot = it }
                     // 同 key 已有更新的写入时不移除,继续由新条目覆盖
                     if (pending[key] === write) pending.remove(key)
                     rebuild()

+ 6 - 3
app/src/main/java/io/legado/app/help/config/DsSync.kt

@@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeoutOrNull
@@ -62,10 +63,12 @@ object DsSync {
      * 用于 restart 等必须确保"写入已持久化"的场景。
      */
     suspend fun awaitPendingWrites(timeoutMs: Long = 3000) {
-        withContext(writeDispatcher) {
-            withTimeoutOrNull(timeoutMs) {
-                // 在串行 dispatcher 上执行一个空任务:排在前面的写入全部完成后才会轮到它
+        val startTime = System.currentTimeMillis()
+        while (pendingCount.get() > 0) {
+            if (System.currentTimeMillis() - startTime > timeoutMs) {
+                break
             }
+            delay(50)
         }
     }
 

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

@@ -23,7 +23,7 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
  * 取 key 对应的原始存储值(不做类型断言)。
  * 声明为 Any? 避免泛型擦除后的 checkcast,存储类型不匹配也不会抛 ClassCastException。
  */
-fun Preferences.rawPrefValue(key: String): Any? = this[stringPreferencesKey(key)]
+fun Preferences.rawPrefValue(key: String): Any? = asMap()[stringPreferencesKey(key)]
 
 fun Preferences.compatDsString(key: String): String? = rawPrefValue(key) as? String
 

+ 11 - 6
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -24,6 +24,7 @@ import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.putPrefLong
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.putPrefStringSync
+import io.legado.app.utils.removePref
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
@@ -98,12 +99,16 @@ fun <T> prefDelegate(
         override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
             if (currentValue != value) {
                 updateValue(value)
-                when (value) {
-                    is String? -> appCtx.putPrefString(key, value)
-                    is Int -> appCtx.putPrefInt(key, value)
-                    is Boolean -> appCtx.putPrefBoolean(key, value)
-                    is Long -> appCtx.putPrefLong(key, value)
-                    is Float -> appCtx.putPrefFloat(key, value)
+                if (value == null) {
+                    appCtx.removePref(key)
+                } else {
+                    when (value) {
+                        is String -> appCtx.putPrefString(key, value)
+                        is Int -> appCtx.putPrefInt(key, value)
+                        is Boolean -> appCtx.putPrefBoolean(key, value)
+                        is Long -> appCtx.putPrefLong(key, value)
+                        is Float -> appCtx.putPrefFloat(key, value)
+                    }
                 }
                 onValueChange?.invoke(value)
             }

+ 77 - 0
app/src/test/java/io/legado/app/help/config/PendingOverlayCoreTest.kt

@@ -112,4 +112,81 @@ class PendingOverlayCoreTest {
         assertEquals(2, h.dsState.compatDsInt("b"))
         assertEquals(h.dsState, h.core.preferencesFlow.value)
     }
+
+    @Test
+    fun `写入异常时-overlay仍被清空且回退到磁盘旧值`() {
+        var throwError = false
+        var dsState: Preferences = mutablePreferencesOf(intPreferencesKey("k") to 1)
+        val writeQueue = ArrayDeque<suspend () -> Unit>()
+        val core = PendingOverlayCore(
+            initial = dsState,
+            launchWrite = { writeQueue += it },
+            persist = { key, value ->
+                if (throwError) {
+                    throw java.io.IOException("Disk full")
+                }
+                val mutable = dsState.toMutablePreferences()
+                mutable.setPrefValue(key, value)
+                dsState = mutable.toPreferences()
+                dsState
+            },
+        )
+        core.put("k", 2)
+        assertEquals(2, core.preferencesFlow.value.compatDsInt("k"))
+
+        throwError = true
+        try {
+            runBlocking { writeQueue.removeFirst().invoke() }
+        } catch (e: Exception) {
+            // Expected
+        }
+
+        // 验证 overlay 已被清除,读取回退到快照中的旧值 1
+        assertEquals(1, core.preferencesFlow.value.compatDsInt("k"))
+    }
+
+    @Test
+    fun `同key连续写入-第一次失败第二次成功-最终正确应用第二次的值`() {
+        var firstWrite = true
+        var dsState: Preferences = mutablePreferencesOf(intPreferencesKey("k") to 1)
+        val writeQueue = ArrayDeque<suspend () -> Unit>()
+        val core = PendingOverlayCore(
+            initial = dsState,
+            launchWrite = { writeQueue += it },
+            persist = { key, value ->
+                if (firstWrite) {
+                    firstWrite = false
+                    throw java.io.IOException("Disk full on first write")
+                }
+                val mutable = dsState.toMutablePreferences()
+                mutable.setPrefValue(key, value)
+                dsState = mutable.toPreferences()
+                dsState
+            },
+        )
+        // 第一次写入 2 (失败)
+        core.put("k", 2)
+        // 第二次写入 3 (成功)
+        core.put("k", 3)
+
+        assertEquals(3, core.preferencesFlow.value.compatDsInt("k"))
+
+        // 执行第一个任务(失败)
+        try {
+            runBlocking { writeQueue.removeFirst().invoke() }
+        } catch (e: Exception) {
+            // Expected
+        }
+
+        // 此时因为 pending 中最新的 write 对象是 3 的写入,且 3 的任务还没跑完,
+        // 即使 2 的写入任务失败清理了,读取也应该保持最新值 3 (来自 overlay),而不是回退到 1
+        assertEquals(3, core.preferencesFlow.value.compatDsInt("k"))
+
+        // 执行第二个任务(成功)
+        runBlocking { writeQueue.removeFirst().invoke() }
+
+        // 第二个成功,快照更新为 3,并且 overlay 彻底清空,读取为 3
+        assertEquals(3, core.preferencesFlow.value.compatDsInt("k"))
+        assertEquals(3, dsState.compatDsInt("k"))
+    }
 }