|
|
@@ -3,31 +3,39 @@ package com.grkj.data.database
|
|
|
import android.content.Context
|
|
|
import android.os.Build
|
|
|
import android.os.Environment
|
|
|
+import androidx.work.CoroutineWorker
|
|
|
import androidx.work.Data
|
|
|
-import androidx.work.Worker
|
|
|
import androidx.work.WorkerParameters
|
|
|
import com.grkj.data.utils.event.BackupCompleteEvent
|
|
|
+import com.grkj.data.utils.event.LoadingEvent
|
|
|
import com.grkj.shared.config.AESConfig
|
|
|
+import com.grkj.shared.utils.i18n.I18nManager
|
|
|
import com.sik.sikcore.date.TimeUtils
|
|
|
-import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
|
|
|
+import kotlinx.coroutines.withTimeoutOrNull
|
|
|
import java.io.File
|
|
|
import java.io.FileInputStream
|
|
|
import java.io.FileOutputStream
|
|
|
+import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
|
|
|
|
|
|
/**
|
|
|
* 备份 Worker:把当前 SQLCipher 库导出为**单文件加密备份**
|
|
|
* - 备份文件落在 /sdcard/ISCS/backup/backup_yyyyMMdd_HHmmss.db
|
|
|
* - 输入参数:KEEP_COUNT(保留份数,默认10)
|
|
|
*/
|
|
|
-class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
|
|
+class RoomBackupWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
|
|
|
|
|
- override fun doWork(): Result {
|
|
|
+ override suspend fun doWork(): Result {
|
|
|
return try {
|
|
|
+ withTimeoutOrNull(60_000) {
|
|
|
+ DbReadyGate.await()
|
|
|
+ } ?: return Result.retry()
|
|
|
// 0) 权限兜底(UI 层最好先确保授权)
|
|
|
if (!canWritePublicDir(applicationContext)) {
|
|
|
- return Result.failure(Data.Builder().putString("reason", "NO_WRITE_PERMISSION").build())
|
|
|
+ return Result.failure(
|
|
|
+ Data.Builder().putString("reason", "NO_WRITE_PERMISSION").build()
|
|
|
+ )
|
|
|
}
|
|
|
-
|
|
|
+ LoadingEvent.sendLoadingEvent(I18nManager.t("data_in_backup"), isCanCancel = false)
|
|
|
// 1) 与 Room 协作:先 checkpoint,再关闭连接,释放文件锁
|
|
|
runCatching { ISCSDatabase.checkpointAndCloseForMaintenance() }
|
|
|
|
|
|
@@ -46,7 +54,7 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
val pass = AESConfig.defaultConfig.key()
|
|
|
|
|
|
// exportCipherToCipherWithLockRetry(srcDb, tmp, pass)
|
|
|
- coldCopyCipherDb(srcDb,tmp)
|
|
|
+ coldCopyCipherDb(srcDb, tmp)
|
|
|
check(verifyCanOpen(tmp, pass)) { "备份校验失败:临时备份无法打开" }
|
|
|
|
|
|
copyOverwrite(tmp, finalOut)
|
|
|
@@ -61,12 +69,14 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
|
|
|
BackupScheduler.scheduleNextFromPrefsSync(applicationContext)
|
|
|
BackupCompleteEvent.sendBackupCompleteEvent(true)
|
|
|
+ LoadingEvent.sendLoadingEvent()
|
|
|
Result.success()
|
|
|
} catch (t: Throwable) {
|
|
|
t.printStackTrace()
|
|
|
// 失败也尽量恢复 Room
|
|
|
runCatching { ISCSDatabase.warmReopen() }
|
|
|
BackupCompleteEvent.sendBackupCompleteEvent(false)
|
|
|
+ DbReadyGate.open()
|
|
|
Result.retry()
|
|
|
}
|
|
|
}
|
|
|
@@ -79,11 +89,16 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
// 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 { } }
|
|
|
- }
|
|
|
+ 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(只是清洁,安全边界依靠“关闭连接”)
|
|
|
@@ -95,7 +110,8 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
dst.parentFile?.mkdirs()
|
|
|
FileInputStream(src).channel.use { inCh ->
|
|
|
FileOutputStream(dst).channel.use { outCh ->
|
|
|
- var pos = 0L; val size = inCh.size()
|
|
|
+ var pos = 0L;
|
|
|
+ val size = inCh.size()
|
|
|
while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
|
|
|
outCh.force(true) // fsync
|
|
|
}
|
|
|
@@ -155,11 +171,17 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
private inline fun <T> retry(times: Int, sleepMs: Long, block: () -> T): T {
|
|
|
var last: Throwable? = null
|
|
|
repeat(times) { i ->
|
|
|
- try { return block() } catch (t: Throwable) {
|
|
|
+ try {
|
|
|
+ return block()
|
|
|
+ } catch (t: Throwable) {
|
|
|
last = t
|
|
|
- val locked = (t is LockedRetry) || t.message?.contains("database is locked", true) == true
|
|
|
+ val locked =
|
|
|
+ (t is LockedRetry) || t.message?.contains("database is locked", true) == true
|
|
|
if (locked) {
|
|
|
- try { Thread.sleep(sleepMs * (i + 1)) } catch (_: InterruptedException) {}
|
|
|
+ try {
|
|
|
+ Thread.sleep(sleepMs * (i + 1))
|
|
|
+ } catch (_: InterruptedException) {
|
|
|
+ }
|
|
|
} else {
|
|
|
throw t
|
|
|
}
|
|
|
@@ -177,7 +199,8 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
dst.parentFile?.mkdirs()
|
|
|
FileInputStream(src).channel.use { inCh ->
|
|
|
FileOutputStream(dst).channel.use { outCh ->
|
|
|
- var pos = 0L; val size = inCh.size()
|
|
|
+ var pos = 0L;
|
|
|
+ val size = inCh.size()
|
|
|
while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
|
|
|
outCh.force(true)
|
|
|
}
|
|
|
@@ -188,9 +211,7 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
|
|
|
|
|
|
private fun cleanupOld(dir: File, keep: Int) {
|
|
|
dir.listFiles { f -> f.isFile && f.name.startsWith("backup_") && f.name.endsWith(".db") }
|
|
|
- ?.sortedByDescending { it.lastModified() }
|
|
|
- ?.drop(keep)
|
|
|
- ?.forEach { it.delete() }
|
|
|
+ ?.sortedByDescending { it.lastModified() }?.drop(keep)?.forEach { it.delete() }
|
|
|
}
|
|
|
|
|
|
companion object {
|