@@ -23,9 +23,10 @@ import to.bitkit.data.WidgetsData
2323import to.bitkit.data.WidgetsStore
2424import to.bitkit.data.backup.VssBackupClient
2525import to.bitkit.data.resetPin
26- import to.bitkit.di.BgDispatcher
26+ import to.bitkit.di.IoDispatcher
2727import to.bitkit.di.json
2828import to.bitkit.ext.formatPlural
29+ import to.bitkit.ext.nowMillis
2930import to.bitkit.models.ActivityBackupV1
3031import to.bitkit.models.BackupCategory
3132import to.bitkit.models.BackupItemStatus
@@ -36,14 +37,15 @@ import to.bitkit.models.WalletBackupV1
3637import to.bitkit.services.LightningService
3738import to.bitkit.ui.shared.toast.ToastEventBus
3839import to.bitkit.utils.Logger
40+ import to.bitkit.utils.jsonLogOf
3941import javax.inject.Inject
4042import javax.inject.Singleton
4143
4244@Suppress(" LongParameterList" )
4345@Singleton
4446class BackupRepo @Inject constructor(
4547 @ApplicationContext private val context : Context ,
46- @BgDispatcher private val bgDispatcher : CoroutineDispatcher ,
48+ @IoDispatcher private val ioDispatcher : CoroutineDispatcher ,
4749 private val cacheStore : CacheStore ,
4850 private val vssBackupClient : VssBackupClient ,
4951 private val settingsStore : SettingsStore ,
@@ -54,7 +56,7 @@ class BackupRepo @Inject constructor(
5456 private val clock : Clock ,
5557 private val db : AppDb ,
5658) {
57- private val scope = CoroutineScope (bgDispatcher + SupervisorJob ())
59+ private val scope = CoroutineScope (ioDispatcher + SupervisorJob ())
5860
5961 private val backupJobs = mutableMapOf<BackupCategory , Job >()
6062 private val statusObserverJobs = mutableListOf<Job >()
@@ -65,6 +67,11 @@ class BackupRepo @Inject constructor(
6567
6668 private var lastNotificationTime = 0L
6769
70+ fun reset () {
71+ stopObservingBackups()
72+ vssBackupClient.reset()
73+ }
74+
6875 fun startObservingBackups () {
6976 if (isObserving) return
7077
@@ -112,7 +119,7 @@ class BackupRepo @Inject constructor(
112119 old.synced == new.synced && old.required == new.required
113120 }
114121 .collect { status ->
115- if (status.synced < status.required && ! status.running && ! isRestoring) {
122+ if (status.isRequired && ! status.running && ! isRestoring) {
116123 scheduleBackup(category)
117124 }
118125 }
@@ -249,12 +256,22 @@ class BackupRepo @Inject constructor(
249256 Logger .verbose(" Scheduling backup for: '$category '" , context = TAG )
250257
251258 backupJobs[category] = scope.launch {
259+ // Set running immediately to prevent UI showing failure during debounce
260+ cacheStore.updateBackupStatus(category) {
261+ it.copy(running = true )
262+ }
263+
252264 delay(BACKUP_DEBOUNCE )
253265
254266 // Double-check if backup is still needed
255267 val status = cacheStore.backupStatuses.first()[category] ? : BackupItemStatus ()
256- if (status.synced < status.required && ! status.running && ! isRestoring) {
268+ if (status.isRequired && ! isRestoring) {
257269 triggerBackup(category)
270+ } else {
271+ // Backup no longer needed, reset running flag
272+ cacheStore.updateBackupStatus(category) {
273+ it.copy(running = false )
274+ }
258275 }
259276 }
260277 }
@@ -268,7 +285,7 @@ class BackupRepo @Inject constructor(
268285 val hasFailedBackups = BackupCategory .entries.any { category ->
269286 val status = backupStatuses[category] ? : BackupItemStatus ()
270287
271- val isPendingAndOverdue = status.synced < status.required &&
288+ val isPendingAndOverdue = status.isRequired &&
272289 currentTime - status.required > FAILED_BACKUP_CHECK_TIME
273290 return @any isPendingAndOverdue
274291 }
@@ -290,13 +307,13 @@ class BackupRepo @Inject constructor(
290307 type = Toast .ToastType .ERROR ,
291308 title = context.getString(R .string.settings__backup__failed_title),
292309 description = context.getString(R .string.settings__backup__failed_message).formatPlural(
293- mapOf (" interval" to (BACKUP_CHECK_INTERVAL / 60000 )) // displayed in minutes
310+ mapOf (" interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS ))
294311 ),
295312 )
296313 }
297314 }
298315
299- suspend fun triggerBackup (category : BackupCategory ) = withContext(bgDispatcher ) {
316+ suspend fun triggerBackup (category : BackupCategory ) = withContext(ioDispatcher ) {
300317 Logger .debug(" Backup starting for: '$category '" , context = TAG )
301318
302319 cacheStore.updateBackupStatus(category) {
@@ -385,12 +402,23 @@ class BackupRepo @Inject constructor(
385402 BackupCategory .LIGHTNING_CONNECTIONS -> throw NotImplementedError (" LIGHTNING backup is managed by ldk-node" )
386403 }
387404
388- suspend fun performFullRestoreFromLatestBackup (): Result <Unit > = withContext(bgDispatcher) {
405+ suspend fun performFullRestoreFromLatestBackup (
406+ onCacheRestored : suspend () -> Unit = {},
407+ ): Result <Unit > = withContext(ioDispatcher) {
389408 Logger .debug(" Full restore starting" , context = TAG )
390409
391410 isRestoring = true
392411
393412 return @withContext try {
413+ performRestore(BackupCategory .METADATA ) { dataBytes ->
414+ val parsed = json.decodeFromString<MetadataBackupV1 >(String (dataBytes))
415+ cacheStore.update { parsed.cache }
416+ Logger .debug(" Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))} " , TAG )
417+ onCacheRestored()
418+ db.tagMetadataDao().upsert(parsed.tagMetadata)
419+ Logger .debug(" Restored caches and ${parsed.tagMetadata.size} tags metadata records" , TAG )
420+ }
421+
394422 performRestore(BackupCategory .SETTINGS ) { dataBytes ->
395423 val parsed = json.decodeFromString<SettingsData >(String (dataBytes)).resetPin()
396424 settingsStore.update { parsed }
@@ -404,12 +432,6 @@ class BackupRepo @Inject constructor(
404432 db.transferDao().upsert(parsed.transfers)
405433 Logger .debug(" Restored ${parsed.transfers.size} transfers" , context = TAG )
406434 }
407- performRestore(BackupCategory .METADATA ) { dataBytes ->
408- val parsed = json.decodeFromString<MetadataBackupV1 >(String (dataBytes))
409- db.tagMetadataDao().upsert(parsed.tagMetadata)
410- cacheStore.update { parsed.cache }
411- Logger .debug(" Restored caches and ${parsed.tagMetadata.size} tags metadata records" , TAG )
412- }
413435 performRestore(BackupCategory .BLOCKTANK ) { dataBytes ->
414436 val parsed = json.decodeFromString<BlocktankBackupV1 >(String (dataBytes))
415437 blocktankRepo.restoreFromBackup(parsed).onSuccess {
@@ -436,6 +458,15 @@ class BackupRepo @Inject constructor(
436458 }
437459 }
438460
461+ fun scheduleFullBackup () {
462+ Logger .debug(" Scheduling backups for all categories" , context = TAG )
463+ BackupCategory .entries
464+ .filter { it != BackupCategory .LIGHTNING_CONNECTIONS }
465+ .forEach {
466+ scheduleBackup(it)
467+ }
468+ }
469+
439470 private suspend fun performRestore (
440471 category : BackupCategory ,
441472 restoreAction : suspend (ByteArray ) -> Unit ,
@@ -453,16 +484,18 @@ class BackupRepo @Inject constructor(
453484 Logger .debug(" Restore error for: '$category '" , context = TAG )
454485 }
455486
487+ val now = currentTimeMillis()
456488 cacheStore.updateBackupStatus(category) {
457- it.copy(running = false , synced = currentTimeMillis() )
489+ it.copy(running = false , synced = now, required = now )
458490 }
459491 }
460492
461- private fun currentTimeMillis (): Long = clock.now().toEpochMilliseconds( )
493+ private fun currentTimeMillis (): Long = nowMillis(clock )
462494
463495 companion object {
464496 private const val TAG = " BackupRepo"
465497
498+ private const val MINUTE_IN_MS = 60_000
466499 private const val BACKUP_DEBOUNCE = 5000L // 5 seconds
467500 private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute
468501 private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes
0 commit comments