From c62970294941b664a58613af20edca3af57ee906 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 5 Nov 2025 17:44:17 +0100 Subject: [PATCH 01/14] refactor: use IO dispatcher for backups --- .../java/to/bitkit/data/backup/VssBackupClient.kt | 12 ++++++------ .../main/java/to/bitkit/repositories/BackupRepo.kt | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index 76c74361c..6acfc39d1 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import to.bitkit.data.keychain.Keychain -import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -20,13 +20,13 @@ import kotlin.time.Duration.Companion.seconds @Singleton class VssBackupClient @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val vssStoreIdProvider: VssStoreIdProvider, private val keychain: Keychain, ) { private val isSetup = CompletableDeferred() - suspend fun setup(walletIndex: Int = 0) = withContext(bgDispatcher) { + suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) { try { withTimeout(30.seconds) { Logger.debug("VSS client setting up…", context = TAG) @@ -50,7 +50,7 @@ class VssBackupClient @Inject constructor( } else { vssNewClient( baseUrl = vssUrl, - storeId = vssStoreId, + storeId = vssStoreIdProvider.getVssStoreId(), ) } isSetup.complete(Unit) @@ -65,7 +65,7 @@ class VssBackupClient @Inject constructor( suspend fun putObject( key: String, data: ByteArray, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { isSetup.await() Logger.verbose("VSS 'putObject' call for '$key'", context = TAG) runCatching { @@ -80,7 +80,7 @@ class VssBackupClient @Inject constructor( } } - suspend fun getObject(key: String): Result = withContext(bgDispatcher) { + suspend fun getObject(key: String): Result = withContext(ioDispatcher) { isSetup.await() Logger.verbose("VSS 'getObject' call for '$key'", context = TAG) runCatching { diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 00a980634..117a856e2 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -23,7 +23,7 @@ import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.resetPin -import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.models.ActivityBackupV1 @@ -43,7 +43,7 @@ import javax.inject.Singleton @Singleton class BackupRepo @Inject constructor( @ApplicationContext private val context: Context, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: CacheStore, private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, @@ -54,7 +54,7 @@ class BackupRepo @Inject constructor( private val clock: Clock, private val db: AppDb, ) { - private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val backupJobs = mutableMapOf() private val statusObserverJobs = mutableListOf() @@ -296,7 +296,7 @@ class BackupRepo @Inject constructor( } } - suspend fun triggerBackup(category: BackupCategory) = withContext(bgDispatcher) { + suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) cacheStore.updateBackupStatus(category) { @@ -385,7 +385,7 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } - suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { + suspend fun performFullRestoreFromLatestBackup(): Result = withContext(ioDispatcher) { Logger.debug("Full restore starting", context = TAG) isRestoring = true From 4797bd981a6eff1b486f5fdecc588baad6a26089 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 5 Nov 2025 17:46:10 +0100 Subject: [PATCH 02/14] refactor: remove redundant retention decorators --- app/src/main/java/to/bitkit/di/DispatchersModule.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/di/DispatchersModule.kt b/app/src/main/java/to/bitkit/di/DispatchersModule.kt index d769f672c..408a9ffe6 100644 --- a/app/src/main/java/to/bitkit/di/DispatchersModule.kt +++ b/app/src/main/java/to/bitkit/di/DispatchersModule.kt @@ -11,15 +11,12 @@ import kotlinx.coroutines.Dispatchers import javax.inject.Qualifier @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class UiDispatcher @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class BgDispatcher @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class IoDispatcher @Module From 748a021f6059f20d3a99fb6f0ada42f2e98c111c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 11:56:44 +0100 Subject: [PATCH 03/14] fix: backup status epoch date and failure flash WIP --- .../java/to/bitkit/models/BackupCategory.kt | 4 +-- .../java/to/bitkit/repositories/BackupRepo.kt | 26 +++++++++++++++++-- .../to/bitkit/viewmodels/WalletViewModel.kt | 23 ++++++++-------- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 7944ea710..feecab12e 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -57,6 +57,6 @@ enum class BackupCategory( @Serializable data class BackupItemStatus( val running: Boolean = false, - val synced: Long = 0L, - val required: Long = 0L, + val synced: Long = 0, + val required: Long = 0, ) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 117a856e2..918cecb4d 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -249,12 +249,22 @@ class BackupRepo @Inject constructor( Logger.verbose("Scheduling backup for: '$category'", context = TAG) backupJobs[category] = scope.launch { + // Set running immediately to prevent UI showing failure during debounce + cacheStore.updateBackupStatus(category) { + it.copy(running = true) + } + delay(BACKUP_DEBOUNCE) // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.synced < status.required && !status.running && !isRestoring) { + if (status.synced < status.required && !isRestoring) { triggerBackup(category) + } else { + // Backup no longer needed, reset running flag + cacheStore.updateBackupStatus(category) { + it.copy(running = false) + } } } } @@ -290,7 +300,7 @@ class BackupRepo @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.settings__backup__failed_title), description = context.getString(R.string.settings__backup__failed_message).formatPlural( - mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60000)) // displayed in minutes + mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60_000)) // displayed in minutes ), ) } @@ -427,6 +437,9 @@ class BackupRepo @Inject constructor( } Logger.info("Full restore success", context = TAG) + scope.launch { + scheduleFullBackup() + } Result.success(Unit) } catch (e: Throwable) { Logger.warn("Full restore error", e = e, context = TAG) @@ -436,6 +449,15 @@ class BackupRepo @Inject constructor( } } + fun scheduleFullBackup() { + Logger.debug("Scheduling backups for all categories", context = TAG) + BackupCategory.entries + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .forEach { + scheduleBackup(it) + } + } + private suspend fun performRestore( category: BackupCategory, restoreAction: suspend (ByteArray) -> Unit, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 37a69d1f5..7ca01407b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -88,7 +88,7 @@ class WalletViewModel @Inject constructor( ) } if (state.walletExists && restoreState == RestoreState.RestoringWallet) { - triggerBackupRestore() + restoreFromBackup() } } } @@ -108,14 +108,11 @@ class WalletViewModel @Inject constructor( } } - private fun triggerBackupRestore() { + private suspend fun restoreFromBackup() { restoreState = RestoreState.RestoringBackups - - viewModelScope.launch { - backupRepo.performFullRestoreFromLatestBackup() - // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up - restoreState = RestoreState.BackupRestoreCompleted - } + backupRepo.performFullRestoreFromLatestBackup() + // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up + restoreState = RestoreState.BackupRestoreCompleted } fun setRestoringWalletState() { @@ -245,9 +242,13 @@ class WalletViewModel @Inject constructor( } suspend fun createWallet(bip39Passphrase: String?) { - walletRepo.createWallet(bip39Passphrase).onFailure { error -> - ToastEventBus.send(error) - } + walletRepo.createWallet(bip39Passphrase) + .onSuccess { + backupRepo.scheduleFullBackup() + } + .onFailure { error -> + ToastEventBus.send(error) + } } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { From fcb3cd7e06ba14601caef5fda2081dd3d5f504ca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 14:17:43 +0100 Subject: [PATCH 04/14] refactor: encapsulate logic in backup models --- app/src/main/java/to/bitkit/models/BackupCategory.kt | 4 +++- .../main/java/to/bitkit/repositories/BackupRepo.kt | 6 +++--- .../to/bitkit/ui/settings/BackupSettingsScreen.kt | 11 +++++------ .../java/to/bitkit/viewmodels/BackupsViewModel.kt | 8 ++------ 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index feecab12e..519d85964 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -59,4 +59,6 @@ data class BackupItemStatus( val running: Boolean = false, val synced: Long = 0, val required: Long = 0, -) +) { + val isRequired: Boolean get() = synced < required +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 918cecb4d..9cbd158ce 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -112,7 +112,7 @@ class BackupRepo @Inject constructor( old.synced == new.synced && old.required == new.required } .collect { status -> - if (status.synced < status.required && !status.running && !isRestoring) { + if (status.isRequired && !status.running && !isRestoring) { scheduleBackup(category) } } @@ -258,7 +258,7 @@ class BackupRepo @Inject constructor( // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.synced < status.required && !isRestoring) { + if (status.isRequired && !isRestoring) { triggerBackup(category) } else { // Backup no longer needed, reset running flag @@ -278,7 +278,7 @@ class BackupRepo @Inject constructor( val hasFailedBackups = BackupCategory.entries.any { category -> val status = backupStatuses[category] ?: BackupItemStatus() - val isPendingAndOverdue = status.synced < status.required && + val isPendingAndOverdue = status.isRequired && currentTime - status.required > FAILED_BACKUP_CHECK_TIME return@any isPendingAndOverdue } diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index c14ec3ac2..bf4a1e942 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -91,7 +91,7 @@ private fun BackupSettingsScreenContent( onBack: () -> Unit, onClose: () -> Unit, ) { - val allSynced = uiState.categories.all { it.status.synced >= it.status.required } + val allSynced = uiState.categories.all { !it.status.isRequired } ScreenColumn { AppTopBar( titleText = stringResource(R.string.settings__backup__title), @@ -158,7 +158,7 @@ private fun BackupStatusItem( val subtitle = when { status.running -> "Running" // TODO add missing localized text - status.synced >= status.required -> stringResource(R.string.settings__backup__status_success) + !status.isRequired -> stringResource(R.string.settings__backup__status_success) .replace("{time}", status.synced.toLocalizedTimestamp()) else -> stringResource(R.string.settings__backup__status_failed) @@ -182,7 +182,7 @@ private fun BackupStatusItem( CaptionB(text = subtitle, color = Colors.White64, maxLines = 1) } - val showRetry = !uiState.disableRetry && !status.running && status.synced < status.required + val showRetry = !uiState.disableRetry && !status.running && status.isRequired if (showRetry) { BackupRetryButton( onClick = { onRetryClick(uiState.category) }, @@ -204,7 +204,7 @@ private fun BackupStatusIcon( .background( color = when { status.running -> Colors.Yellow16 - status.synced >= status.required -> Colors.Green16 + !status.isRequired -> Colors.Green16 else -> Colors.Red16 }, shape = CircleShape @@ -215,7 +215,7 @@ private fun BackupStatusIcon( contentDescription = null, tint = when { status.running -> Colors.Yellow - status.synced >= status.required -> Colors.Green + !status.isRequired -> Colors.Green else -> Colors.Red }, modifier = Modifier.size(16.dp) @@ -251,7 +251,6 @@ private fun Preview() { val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000) when (it.category) { - BackupCategory.LIGHTNING_CONNECTIONS -> it.copy(disableRetry = true) BackupCategory.WALLET -> it.copy(status = BackupItemStatus(running = true, required = 1)) BackupCategory.METADATA -> it.copy(status = BackupItemStatus(required = 1)) else -> it.copy(status = BackupItemStatus(synced = timestamp, required = timestamp)) diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 5abe7ad31..6ef0f4cd2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -35,12 +35,7 @@ class BackupsViewModel @Inject constructor( cacheStore.backupStatuses.collect { cachedStatuses -> val categories = BackupCategory.entries.map { category -> val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) - category.toUiState(cachedStatus).let { uiState -> - when (category) { - BackupCategory.LIGHTNING_CONNECTIONS -> uiState.copy(disableRetry = true) - else -> uiState - } - } + category.toUiState(cachedStatus) } _uiState.update { it.copy(categories = categories) } } @@ -85,5 +80,6 @@ fun BackupCategory.toUiState(status: BackupItemStatus = BackupItemStatus()): Bac return BackupCategoryUiState( category = this, status = status, + disableRetry = this == BackupCategory.LIGHTNING_CONNECTIONS, ) } From 8092531f09b0739b7b656ca952e614b172d66e03 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 16:18:32 +0100 Subject: [PATCH 05/14] fix: red backup status after restore --- app/src/main/java/to/bitkit/ext/DateTime.kt | 3 +++ app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 10 +++++----- app/src/main/java/to/bitkit/repositories/HealthRepo.kt | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index c746ba7be..e67045cd1 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -4,6 +4,7 @@ package to.bitkit.ext import android.icu.text.DateFormat import android.icu.util.ULocale +import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -22,6 +23,8 @@ import java.util.Locale import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds +fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String { diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 9cbd158ce..a952dd306 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -26,6 +26,7 @@ import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural +import to.bitkit.ext.nowMillis import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -437,14 +438,12 @@ class BackupRepo @Inject constructor( } Logger.info("Full restore success", context = TAG) - scope.launch { - scheduleFullBackup() - } Result.success(Unit) } catch (e: Throwable) { Logger.warn("Full restore error", e = e, context = TAG) Result.failure(e) } finally { + scheduleFullBackup() isRestoring = false } } @@ -475,12 +474,13 @@ class BackupRepo @Inject constructor( Logger.debug("Restore error for: '$category'", context = TAG) } + val now = currentTimeMillis() cacheStore.updateBackupStatus(category) { - it.copy(running = false, synced = currentTimeMillis()) + it.copy(running = false, synced = now, required = now) } } - private fun currentTimeMillis(): Long = clock.now().toEpochMilliseconds() + private fun currentTimeMillis(): Long = nowMillis(clock) companion object { private const val TAG = "BackupRepo" diff --git a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt index 67a2cf445..72b9a211d 100644 --- a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt @@ -126,7 +126,7 @@ class HealthRepo @Inject constructor( val now = clock.now().toEpochMilliseconds() fun isSyncOk(synced: Long, required: Long) = - synced > required || (now - required) < 5.minutes.inWholeMilliseconds + synced >= required || (now - required) < 5.minutes.inWholeMilliseconds val isBackupSyncOk = BackupCategory.entries .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } From 70e3c3fde6a9541fac103b270ba94a745e145b1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 16:50:27 +0100 Subject: [PATCH 06/14] fix: stop backup observers before wipe --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3d23b23d3..91f309dfd 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor( private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val vssStoreIdProvider: VssStoreIdProvider, + private val backupRepo: BackupRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -242,6 +243,8 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + backupRepo.stopObservingBackups() + try { keychain.wipe() vssStoreIdProvider.clearCache(walletIndex) From f4e5677c867627a694e05d3238a493e88e1833fb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 16:51:26 +0100 Subject: [PATCH 07/14] chore: update bitkit-core to 0.1.23 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f9c7e81e..4158f5461 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.22" } +bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.23" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From b0a344be0558f7f78184d2dafd5884678ab33150 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 20:15:05 +0100 Subject: [PATCH 08/14] fix: empty balance flash on restore --- .../java/to/bitkit/repositories/BackupRepo.kt | 21 ++++++++++++------- .../java/to/bitkit/repositories/WalletRepo.kt | 5 ----- .../to/bitkit/viewmodels/WalletViewModel.kt | 5 ++++- .../to/bitkit/repositories/WalletRepoTest.kt | 7 +++++++ 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a952dd306..e153d4b98 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -301,7 +301,7 @@ class BackupRepo @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.settings__backup__failed_title), description = context.getString(R.string.settings__backup__failed_message).formatPlural( - mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60_000)) // displayed in minutes + mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) // displayed in minutes ), ) } @@ -396,12 +396,22 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } - suspend fun performFullRestoreFromLatestBackup(): Result = withContext(ioDispatcher) { + suspend fun performFullRestoreFromLatestBackup( + onCacheRestored: suspend () -> Unit = {}, + ): Result = withContext(ioDispatcher) { Logger.debug("Full restore starting", context = TAG) isRestoring = true return@withContext try { + performRestore(BackupCategory.METADATA) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + db.tagMetadataDao().upsert(parsed.tagMetadata) + cacheStore.update { parsed.cache } + onCacheRestored() + Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) + } + performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)).resetPin() settingsStore.update { parsed } @@ -415,12 +425,6 @@ class BackupRepo @Inject constructor( db.transferDao().upsert(parsed.transfers) Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) } - performRestore(BackupCategory.METADATA) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - db.tagMetadataDao().upsert(parsed.tagMetadata) - cacheStore.update { parsed.cache } - Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) - } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) blocktankRepo.restoreFromBackup(parsed).onSuccess { @@ -485,6 +489,7 @@ class BackupRepo @Inject constructor( companion object { private const val TAG = "BackupRepo" + private const val MINUTE_IN_MS = 60_000 private const val BACKUP_DEBOUNCE = 5000L // 5 seconds private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 91f309dfd..5c5627488 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -62,11 +62,6 @@ class WalletRepo @Inject constructor( private val _balanceState = MutableStateFlow(BalanceState()) val balanceState = _balanceState.asStateFlow() - init { - // Load from cache once on init - loadFromCache() - } - fun loadFromCache() { // TODO try keeping in sync with cache if performant and reliable repoScope.launch { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7ca01407b..564f47d73 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -69,6 +69,9 @@ class WalletViewModel @Inject constructor( private fun walletEffect(effect: WalletViewModelEffects) = viewModelScope.launch { _walletEffect.emit(effect) } init { + if (walletExists) { + walletRepo.loadFromCache() + } collectStates() } @@ -110,7 +113,7 @@ class WalletViewModel @Inject constructor( private suspend fun restoreFromBackup() { restoreState = RestoreState.RestoringBackups - backupRepo.performFullRestoreFromLatestBackup() + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up restoreState = RestoreState.BackupRestoreCompleted } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index cfb0835b6..cf4375ddf 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -48,6 +48,7 @@ class WalletRepoTest : BaseUnitTest() { private val cacheStore: CacheStore = mock() private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() private val vssStoreIdProvider = mock() + private val backupRepo = mock() @Before fun setUp() { @@ -78,6 +79,7 @@ class WalletRepoTest : BaseUnitTest() { cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, vssStoreIdProvider = vssStoreIdProvider, + backupRepo = backupRepo, ) @Test @@ -196,6 +198,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = existingAddress))) whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) sut = createSut() + sut.loadFromCache() val result = sut.refreshBip21() @@ -266,6 +269,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success("testInvoice")) sut = createSut() + sut.loadFromCache() sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> assertTrue(result.isSuccess) @@ -524,6 +528,7 @@ class WalletRepoTest : BaseUnitTest() { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) sut = createSut() + sut.loadFromCache() sut.setBolt11("existingInvoice") whenever(lightningRepo.canReceive()).thenReturn(false) @@ -570,6 +575,7 @@ class WalletRepoTest : BaseUnitTest() { ) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) sut = createSut() + sut.loadFromCache() sut.refreshBip21ForEvent( Event.PaymentReceived( @@ -589,6 +595,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) sut = createSut() + sut.loadFromCache() sut.refreshBip21ForEvent( Event.PaymentReceived( From 522324ad252c37db952c45768d1ff8b8547c86bb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 6 Nov 2025 20:45:31 +0100 Subject: [PATCH 09/14] fix: call onCacheRestored earlier --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index e153d4b98..2a8169ea5 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -301,7 +301,7 @@ class BackupRepo @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.settings__backup__failed_title), description = context.getString(R.string.settings__backup__failed_message).formatPlural( - mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) // displayed in minutes + mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) ), ) } @@ -406,9 +406,9 @@ class BackupRepo @Inject constructor( return@withContext try { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - db.tagMetadataDao().upsert(parsed.tagMetadata) cacheStore.update { parsed.cache } onCacheRestored() + db.tagMetadataDao().upsert(parsed.tagMetadata) Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) } From c6c101bf420a52599d3d43010deda1b02a834899 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 00:38:06 +0100 Subject: [PATCH 10/14] fix: reset backup client on wipe Fix issue where restoring wallet B from seed resulted in restoring the backup data of wallet A --- .../main/java/to/bitkit/data/backup/VssBackupClient.kt | 10 ++++++++-- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 5 ++++- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 7 ++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index 6acfc39d1..2c85704c6 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -24,7 +24,7 @@ class VssBackupClient @Inject constructor( private val vssStoreIdProvider: VssStoreIdProvider, private val keychain: Keychain, ) { - private val isSetup = CompletableDeferred() + private var isSetup = CompletableDeferred() suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) { try { @@ -50,7 +50,7 @@ class VssBackupClient @Inject constructor( } else { vssNewClient( baseUrl = vssUrl, - storeId = vssStoreIdProvider.getVssStoreId(), + storeId = vssStoreId, ) } isSetup.complete(Unit) @@ -62,6 +62,12 @@ class VssBackupClient @Inject constructor( } } + fun reset() { + isSetup = CompletableDeferred() + vssStoreIdProvider.clearCache() + Logger.debug("VSS client reset", context = TAG) + } + suspend fun putObject( key: String, data: ByteArray, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2a8169ea5..0569c31cd 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -37,6 +37,7 @@ import to.bitkit.models.WalletBackupV1 import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.jsonLogOf import javax.inject.Inject import javax.inject.Singleton @@ -66,6 +67,8 @@ class BackupRepo @Inject constructor( private var lastNotificationTime = 0L + fun reset() =vssBackupClient.reset() + fun startObservingBackups() { if (isObserving) return @@ -407,6 +410,7 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) cacheStore.update { parsed.cache } + Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() db.tagMetadataDao().upsert(parsed.tagMetadata) Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) @@ -447,7 +451,6 @@ class BackupRepo @Inject constructor( Logger.warn("Full restore error", e = e, context = TAG) Result.failure(e) } finally { - scheduleFullBackup() isRestoring = false } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 5c5627488..1cb19ee8e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -241,15 +241,16 @@ class WalletRepo @Inject constructor( backupRepo.stopObservingBackups() try { + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } + keychain.wipe() - vssStoreIdProvider.clearCache(walletIndex) + backupRepo.reset() db.clearAllTables() settingsStore.reset() cacheStore.reset() // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities coreService.activity.removeAll() - _walletState.update { WalletState() } - _balanceState.update { BalanceState() } setWalletExistsState() return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) From 6ee9a50f1414e7924d7b7a83e15afd25d2a946d0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 00:47:21 +0100 Subject: [PATCH 11/14] chore: reformat --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0569c31cd..4c37d15e8 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -67,7 +67,7 @@ class BackupRepo @Inject constructor( private var lastNotificationTime = 0L - fun reset() =vssBackupClient.reset() + fun reset() = vssBackupClient.reset() fun startObservingBackups() { if (isObserving) return From 44bfbc8b1fa779cd053d220296b589438386e454 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 13:11:47 +0100 Subject: [PATCH 12/14] fix: synchronize reset --- app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index 2c85704c6..a8a35027e 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -63,11 +63,13 @@ class VssBackupClient @Inject constructor( } fun reset() { - isSetup = CompletableDeferred() + synchronized(this) { + isSetup.cancel() + isSetup = CompletableDeferred() + } vssStoreIdProvider.clearCache() Logger.debug("VSS client reset", context = TAG) } - suspend fun putObject( key: String, data: ByteArray, From 56dbf882996436abe6212ad72672fc0515c23e86 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 13:13:32 +0100 Subject: [PATCH 13/14] fix: clear all wallet indexes on wipe --- app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index ddcb202e9..a682ed9bc 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -34,8 +34,8 @@ class VssStoreIdProvider @Inject constructor( } } - fun clearCache(walletIndex: Int = 0) { - cacheMap.remove(walletIndex) + fun clearCache() { + cacheMap.clear() } companion object { From b5aace7217c57266ebae5657a0c0611b5fd6a866 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 7 Nov 2025 13:18:31 +0100 Subject: [PATCH 14/14] fix reset backups first on wipe --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 5 ++++- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 5 ++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4c37d15e8..2f40d7116 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -67,7 +67,10 @@ class BackupRepo @Inject constructor( private var lastNotificationTime = 0L - fun reset() = vssBackupClient.reset() + fun reset() { + stopObservingBackups() + vssBackupClient.reset() + } fun startObservingBackups() { if (isObserving) return diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 1cb19ee8e..b8fb5db3b 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -238,14 +238,13 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - backupRepo.stopObservingBackups() - try { + backupRepo.reset() + _walletState.update { WalletState() } _balanceState.update { BalanceState() } keychain.wipe() - backupRepo.reset() db.clearAllTables() settingsStore.reset() cacheStore.reset()