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

fix(R4.2): 主题配置与备份恢复的写盘一并改原子写

R4.2 收尾——同型的另外 4 处「先删后写/后复制」:
- `ThemeConfigStore.save()`:改 `FileUtils.writeTextAtomic`。杀进程会丢整套主题方案。
- `Restore` 恢复主题配置 / 阅读配置 / 共享阅读配置三处:`FileUtils.delete` +
  `copyTo` 改新增的 `FileUtils.copyFileAtomic`。恢复失败或被中断时,原来是旧配置
  已删、新配置没到,用户两头空。

rename 那套动作抽成私有 `replaceAtomic(targetPath) { produce(it) }`,写文本与复制
文件共用,两条路径的原子性不会各自漂移。

**明确不改**:`BookCover.saveCoverRule` 走 CacheManager(数据库不是文件);
`RssSourceViewModel.saveToFile` 是导出用的临时分享文件,本就该每次重建。

测试加 2 条。「复制失败时目标保持旧内容」头一版用「把 .tmp 占成目录」注入失败,
但 `copyTo(overwrite = true)` 会把空目录直接删掉、复制照样成功,导致干净实现下
测试就是红的——改用「源文件不存在」(恢复途中备份文件消失)这个真实场景,
干净实现绿、换回先删后复制即红。全量单测 439 → 441。

回归清单:改主题方案后杀进程重开,主题列表还在;备份 → 恢复后主题配置与阅读
排版配置生效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
07fb4c5c95

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

@@ -96,8 +96,7 @@ object ThemeConfigStore {
 
 
     fun save() {
     fun save() {
         val json = GSON.toJson(configList)
         val json = GSON.toJson(configList)
-        FileUtils.delete(configFilePath)
-        FileUtils.createFileIfNotExist(configFilePath).writeText(json)
+        FileUtils.writeTextAtomic(configFilePath, json)
     }
     }
 
 
     fun addConfig(json: String): Boolean {
     fun addConfig(json: String): Boolean {

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

@@ -321,8 +321,7 @@ object Restore : KoinComponent {
             File(path, ThemeConfigStore.configFileName).takeIf {
             File(path, ThemeConfigStore.configFileName).takeIf {
                 it.exists()
                 it.exists()
             }?.runCatching {
             }?.runCatching {
-                FileUtils.delete(ThemeConfigStore.configFilePath)
-                copyTo(File(ThemeConfigStore.configFilePath))
+                FileUtils.copyFileAtomic(this, ThemeConfigStore.configFilePath)
                 ThemeConfigStore.upConfig()
                 ThemeConfigStore.upConfig()
             }?.onFailure {
             }?.onFailure {
                 AppLog.put("恢复主题出错\n${it.localizedMessage}", it)
                 AppLog.put("恢复主题出错\n${it.localizedMessage}", it)
@@ -341,8 +340,7 @@ object Restore : KoinComponent {
             File(path, ReadBookConfig.configFileName).takeIf {
             File(path, ReadBookConfig.configFileName).takeIf {
                 it.exists()
                 it.exists()
             }?.runCatching {
             }?.runCatching {
-                FileUtils.delete(ReadBookConfig.configFilePath)
-                copyTo(File(ReadBookConfig.configFilePath))
+                FileUtils.copyFileAtomic(this, ReadBookConfig.configFilePath)
                 ReadBookConfig.initConfigs()
                 ReadBookConfig.initConfigs()
             }?.onFailure {
             }?.onFailure {
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
@@ -350,8 +348,7 @@ object Restore : KoinComponent {
             File(path, ReadBookConfig.shareConfigFileName).takeIf {
             File(path, ReadBookConfig.shareConfigFileName).takeIf {
                 it.exists()
                 it.exists()
             }?.runCatching {
             }?.runCatching {
-                FileUtils.delete(ReadBookConfig.shareConfigFilePath)
-                copyTo(File(ReadBookConfig.shareConfigFilePath))
+                FileUtils.copyFileAtomic(this, ReadBookConfig.shareConfigFilePath)
                 ReadBookConfig.initShareConfig()
                 ReadBookConfig.initShareConfig()
             }?.onFailure {
             }?.onFailure {
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
                 AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)

+ 17 - 4
app/src/main/java/io/legado/app/utils/FileUtils.kt

@@ -23,12 +23,25 @@ object FileUtils {
      * 注意:不做 fsync,因此只保证**进程被杀**不丢,不保证掉电不丢。
      * 注意:不做 fsync,因此只保证**进程被杀**不丢,不保证掉电不丢。
      */
      */
     fun writeTextAtomic(filePath: String, text: String) {
     fun writeTextAtomic(filePath: String, text: String) {
-        val target = File(filePath)
-        val temp = File("$filePath.tmp")
-        createFileIfNotExist(temp.absolutePath).writeText(text)
+        replaceAtomic(filePath) { it.writeText(text) }
+    }
+
+    /**
+     * 原子复制覆盖:理由同 [writeTextAtomic]。恢复备份时用,避免删掉旧配置后
+     * 复制失败或被中断,落得两头空。
+     */
+    fun copyFileAtomic(source: File, targetPath: String) {
+        replaceAtomic(targetPath) { source.copyTo(it, overwrite = true) }
+    }
+
+    private inline fun replaceAtomic(targetPath: String, produce: (File) -> Unit) {
+        val target = File(targetPath)
+        val temp = File("$targetPath.tmp")
+        target.parent?.let { createFolderIfNotExist(it) }
+        produce(temp)
         if (!temp.renameTo(target)) {
         if (!temp.renameTo(target)) {
             // 个别文件系统的 rename 不覆盖已存在的目标,退回原地写
             // 个别文件系统的 rename 不覆盖已存在的目标,退回原地写
-            createFileIfNotExist(filePath).writeText(text)
+            produce(target)
             temp.delete()
             temp.delete()
         }
         }
     }
     }

+ 29 - 0
app/src/test/java/io/legado/app/utils/FileUtilsAtomicWriteTest.kt

@@ -62,4 +62,33 @@ class FileUtilsAtomicWriteTest {
             target.readText(),
             target.readText(),
         )
         )
     }
     }
+
+    @Test
+    fun `原子复制覆盖已有文件`() {
+        val source = folder.newFile("backup.json")
+        source.writeText("备份内容")
+        val target = folder.newFile("themeConfig.json")
+        target.writeText("旧内容")
+
+        FileUtils.copyFileAtomic(source, target.absolutePath)
+
+        assertEquals("备份内容", target.readText())
+        assertFalse("临时文件应已被 rename 掉", File("${target.absolutePath}.tmp").exists())
+    }
+
+    @Test
+    fun `复制失败时目标文件保持旧内容`() {
+        // 源不存在——恢复备份途中备份文件消失
+        val source = File(folder.root, "missing-backup.json")
+        val target = folder.newFile("themeConfig.json")
+        target.writeText("旧内容")
+
+        runCatching { FileUtils.copyFileAtomic(source, target.absolutePath) }
+
+        assertEquals(
+            "恢复备份失败后目标必须还是旧配置——先删后复制会让用户两头空",
+            "旧内容",
+            target.readText(),
+        )
+    }
 }
 }