Browse Source

refactor(更新)
- 备份还原流程优化:
- 新增备份还原中提示。
- 数据库备份方式改为冷拷贝,提高备份可靠性。
- 调整数据库关闭和重新打开逻辑,确保备份和还原操作的稳定性。
- 国际化文本更新:
- 新增“备份还原中”的英文和中文翻译。

周文健 2 months ago
parent
commit
964560a143

+ 2 - 1
app/src/main/assets/i18n/en-US.csv

@@ -673,4 +673,5 @@ export_success,text,通用导出成功,导出成功
 no_backup_data,text,暂无备份数据,暂无备份数据
 loading_backup,text,加载备份中,正在读取备份文件
 max_backup_tip,text,备份达到上限,备份数量已经达到上限,继续备份将移除最老的数据。
-switch,text,切换文本,切换
+switch,text,切换文本,切换
+backup_restoring,text,备份还原中,备份还原中……

+ 2 - 1
app/src/main/assets/i18n/zh-CN.csv

@@ -673,4 +673,5 @@ export_success,text,通用导出成功,导出成功
 no_backup_data,text,暂无备份数据,暂无备份数据
 loading_backup,text,加载备份中,正在读取备份文件
 max_backup_tip,text,备份达到上限,备份数量已经达到上限,继续备份将移除最老的数据。
-switch,text,切换文本,切换
+switch,text,切换文本,切换
+backup_restoring,text,备份还原中,备份还原中……

+ 2 - 0
app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/BackupAndRestoreFragment.kt

@@ -244,7 +244,9 @@ class BackupAndRestoreFragment : BaseFragment<FragmentBackupAndRestoreBinding>()
         }
         itemBinding.restore.setDebouncedClickListener {
             TipDialog.showInfo(I18nManager.t("restore_backup_confirm"), onConfirmClick = {
+                LoadingEvent.sendLoadingEvent(I18nManager.t("backup_restoring"))
                 viewModel.restoreBackUp(item).observe(this@BackupAndRestoreFragment) {
+                    LoadingEvent.sendLoadingEvent()
                     showToast(I18nManager.t("restore_backup_success"))
                 }
             })

+ 32 - 18
data/src/main/java/com/grkj/data/database/ISCSDatabase.kt

@@ -61,16 +61,29 @@ abstract class ISCSDatabase : RoomDatabase() {
         private val logger: Logger = LoggerFactory.getLogger(ISCSDatabase::class.java)
         private val MIGRATION_LOCK = Any()
 
+        enum class ReopenMode { NORMAL, RESTORE_STRICT }
+
         // 外部存储目录路径
         val EXTERNAL_DB_FILE: File by lazy {
             File(Environment.getExternalStorageDirectory(), "ISCS/database/$DB_NAME")
         }
 
+        @Volatile
+        private var _instance: ISCSDatabase? = null
+
         @JvmStatic
-        val instance: ISCSDatabase by lazy {
+        val instance: ISCSDatabase
+            get() = _instance ?: synchronized(ISCSDatabase::class.java) {
+                _instance ?: buildDatabase(mode = ReopenMode.NORMAL).also { _instance = it }
+            }
+
+        /**
+         * 构建 Room 实例:强制使用“外部绝对路径 + SQLCipher 工厂”
+         */
+        private fun buildDatabase(mode: ReopenMode): ISCSDatabase {
             val context = SIKCore.getApplication()
 
-            // 目录
+            // 目录与首启落地(保持你原逻辑)
             val parentDir = EXTERNAL_DB_FILE.parentFile
             if (parentDir != null && !parentDir.exists()) {
                 if (!parentDir.mkdirs()) {
@@ -79,8 +92,6 @@ abstract class ISCSDatabase : RoomDatabase() {
                     logger.info("创建目录: ${parentDir.absolutePath}")
                 }
             }
-
-            // 首次落地模板库(可能是明文,也可能本来就是加密库)
             if (!EXTERNAL_DB_FILE.exists()) {
                 runCatching {
                     context.assets.open("data.db").use { input ->
@@ -89,16 +100,12 @@ abstract class ISCSDatabase : RoomDatabase() {
                     logger.info("已从 assets 复制数据库到: ${EXTERNAL_DB_FILE.absolutePath}")
                 }.onFailure { e -> logger.error("复制数据库失败", e) }
             }
-
-            // 确保成为加密库(首启自动迁移)
             ensureCiphered(EXTERNAL_DB_FILE)
 
-            // Room + SQLCipher
+            // SQLCipher Hook
             val passphrase: ByteArray = AESConfig.defaultConfig.key()
             val hook = object : SQLiteDatabaseHook {
-                override fun preKey(connection: SQLiteConnection) { /* no-op */
-                }
-
+                override fun preKey(connection: SQLiteConnection) { /* no-op */ }
                 override fun postKey(connection: SQLiteConnection) {
                     connection.execute("PRAGMA foreign_keys=ON", null, null)
                     connection.executeForString("PRAGMA journal_mode=WAL", null, null)
@@ -108,28 +115,33 @@ abstract class ISCSDatabase : RoomDatabase() {
             // ✅ 修正:把 hook 传进来
             val factory = SupportOpenHelperFactory(passphrase, hook, true)
 
-            val db = Room.databaseBuilder(
+            val builder = Room.databaseBuilder(
                 context,
                 ISCSDatabase::class.java,
-                EXTERNAL_DB_FILE.absolutePath
+                EXTERNAL_DB_FILE.absolutePath // 这里也给绝对路径,配合上面的工厂双保险
             )
                 .openHelperFactory(factory)
-                .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // ✅ 显式 WAL
+                .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING)
                 .addMigrations(*ISCSMigrations.migrationData)
                 .addCallback(object : RoomDatabase.Callback() {
                     override fun onOpen(db: SupportSQLiteDatabase) {
-                        // 数据库已经可读写,放行初始化
                         DbReadyGate.open()
                     }
                 })
-                .build()
 
-            // 强制触发实际打开(避免“懒打开”延后 onOpen 回调)
-            db.openHelper.writableDatabase
+            // 恢复模式下**不要** fallbackToDestructiveMigration,避免清库
+            if (mode == ReopenMode.NORMAL) {
+                // 如需保留 fallback,可在正常运行模式启用:
+                // builder.fallbackToDestructiveMigration()
+            }
 
-            db
+            val db = builder.build()
+            // 触发实际打开
+            db.openHelper.writableDatabase
+            return db
         }
 
+
         /**
          * 提供给备份/维护窗口:
          * - 先做 FULL checkpoint,尽量把 -wal 合并进主库,降低锁竞争
@@ -144,6 +156,8 @@ abstract class ISCSDatabase : RoomDatabase() {
             runCatching { db.close() }
                 .onSuccess { logger.info("Room 已关闭,进入维护窗口") }
                 .onFailure { logger.warn("关闭 Room 失败:${it.message}") }
+
+            try { Thread.sleep(150) } catch (_: InterruptedException) {}
         }
 
         /**

+ 36 - 2
data/src/main/java/com/grkj/data/database/RoomBackupWorker.kt

@@ -45,7 +45,8 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
             val srcDb = ISCSDatabase.EXTERNAL_DB_FILE
             val pass = AESConfig.defaultConfig.key()
 
-            exportCipherToCipherWithLockRetry(srcDb, tmp, pass)
+//            exportCipherToCipherWithLockRetry(srcDb, tmp, pass)
+            coldCopyCipherDb(srcDb,tmp)
             check(verifyCanOpen(tmp, pass)) { "备份校验失败:临时备份无法打开" }
 
             copyOverwrite(tmp, finalOut)
@@ -70,6 +71,39 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
         }
     }
 
+
+    /**
+     * 冷拷贝数据库
+     */
+    private fun coldCopyCipherDb(src: File, dst: File) {
+        // 1) 再保险:把 WAL 合并并截断
+        runCatching {
+            val pass = AESConfig.defaultConfig.key()
+            net.zetetic.database.sqlcipher.SQLiteDatabase
+                .openOrCreateDatabase(src, pass, null, null, null).use { db ->
+                    db.rawExecSQL("PRAGMA busy_timeout=10000")
+                    runCatching { db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { } }
+                }
+        }
+
+        // 2) 删除可能残留的 wal/shm(只是清洁,安全边界依靠“关闭连接”)
+        File(src.parentFile, src.name + "-wal").delete()
+        File(src.parentFile, src.name + "-shm").delete()
+
+        // 3) 直接拷贝主库文件
+        if (dst.exists()) dst.delete()
+        dst.parentFile?.mkdirs()
+        FileInputStream(src).channel.use { inCh ->
+            FileOutputStream(dst).channel.use { outCh ->
+                var pos = 0L; val size = inCh.size()
+                while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
+                outCh.force(true) // fsync
+            }
+        }
+        dst.setReadable(true, false)
+        dst.setWritable(true, false)
+    }
+
     // --- SQLCipher → SQLCipher 导出:busy_timeout + 写锁 + checkpoint + 重试 ---
     private fun exportCipherToCipherWithLockRetry(src: File, dst: File, pass: ByteArray) {
         retry(times = 5, sleepMs = 400) {
@@ -78,7 +112,7 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
                 // 1) 等待锁,拉写锁,尽量把 WAL 合并
                 db.rawExecSQL("PRAGMA busy_timeout=10000")
                 runCatching { db.rawQuery("PRAGMA wal_checkpoint(FULL)", null).use { } }
-                db.rawExecSQL("BEGIN IMMEDIATE")
+                db.rawExecSQL("BEGIN EXCLUSIVE")
 
                 // 2) 读源库版本
                 val userVersion = db.rawQuery("PRAGMA user_version", null).use {