From ed99c6d5f5fa078de275b4d81d757afea3a9c437 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 17 Jul 2026 11:13:30 -0300 Subject: [PATCH 1/5] test: regression test for simulate crash on paykit failure --- .../to/bitkit/repositories/BackupRepoTest.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 84bafb834..6a9078c29 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -40,6 +40,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import javax.inject.Provider +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -111,6 +112,35 @@ class BackupRepoTest : BaseUnitTest() { verify(settingsStore, never()).update(any()) } + @Test + fun `automatic wallet backup marks failure when Paykit snapshot fails`() = test { + whenever { privatePaykitRepo.backupSnapshot() } + .thenReturn(Result.failure(BackupRepoTestError("paykit session missing capabilities"))) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.WALLET to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + assertFalse(backupStatuses.value.getValue(BackupCategory.WALLET).running) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `start observing backs up stale required status after clearing running flag`() = test { val backupStatuses = MutableStateFlow( From 41139bd3d60f2318b323b7c879bf49d24ed8d53a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 17 Jul 2026 11:22:03 -0300 Subject: [PATCH 2/5] fix: handle paykit snapshot failure in wallet backup --- .../java/to/bitkit/repositories/BackupRepo.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0d681c748..51fa0f0b7 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -44,6 +44,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -435,7 +436,11 @@ class BackupRepo @Inject constructor( it.copy(running = true, required = backupRequired) } - vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) + val data = runSuspendCatching { getBackupDataBytes(category) } + .onFailure { markBackupFailed(category, backupRequired, it) } + .getOrNull() ?: return@withContext + + vssBackupClient.putObject(key = category.name, data = data) .onSuccess { runningBackups -= category failedBackupRequired -= category @@ -447,18 +452,20 @@ class BackupRepo @Inject constructor( } Logger.info("Backup succeeded for: '$category'", context = TAG) } - .onFailure { e -> - runningBackups -= category - cacheStore.updateBackupStatus(category) { - if (it.required == backupRequired) { - failedBackupRequired[category] = backupRequired - } else { - failedBackupRequired -= category - } - it.copy(running = false) - } - Logger.error("Backup failed for: '$category'", e, context = TAG) + .onFailure { markBackupFailed(category, backupRequired, it) } + } + + private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { + runningBackups -= category + cacheStore.updateBackupStatus(category) { + if (it.required == backupRequired) { + failedBackupRequired[category] = backupRequired + } else { + failedBackupRequired -= category } + it.copy(running = false) + } + Logger.error("Backup failed for: '$category'", e, context = TAG) } private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) { From 2ba4d65d74d2429fe30e9e0355d2a6d697b6138d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 17 Jul 2026 11:34:56 -0300 Subject: [PATCH 3/5] chore: add changelog fragment --- changelog.d/next/1092.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1092.fixed.md diff --git a/changelog.d/next/1092.fixed.md b/changelog.d/next/1092.fixed.md new file mode 100644 index 000000000..b4fc5af7a --- /dev/null +++ b/changelog.d/next/1092.fixed.md @@ -0,0 +1 @@ +Fixed a crash during automatic wallet backup when Paykit state could not be read. From 854544c2ea130f32dadaf3ab1704d89bd67850c0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 22 Jul 2026 07:08:23 -0300 Subject: [PATCH 4/5] fix: keep backup result type on snapshot failure --- .../main/java/to/bitkit/repositories/BackupRepo.kt | 5 +++-- .../java/to/bitkit/repositories/BackupRepoTest.kt | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 36fe6baaf..817e31641 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -4,6 +4,7 @@ import android.content.Context import com.synonym.bitkitcore.migrateBackupActivitiesJson import com.synonym.bitkitcore.migrateBackupActivityTagsJson import com.synonym.bitkitcore.migrateBackupPreActivityMetadataJson +import com.synonym.vssclient.VssItem import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview @@ -425,7 +426,7 @@ class BackupRepo @Inject constructor( } } - suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { + suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) val backupRequired = currentTimeMillis() @@ -437,7 +438,7 @@ class BackupRepo @Inject constructor( val data = runSuspendCatching { getBackupDataBytes(category) } .onFailure { markBackupFailed(category, backupRequired, it) } - .getOrNull() ?: return@withContext + .getOrElse { return@withContext Result.failure(it) } vssBackupClient.putObject(key = category.name, data = data) .onSuccess { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 6a9078c29..65e71d42a 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -114,6 +114,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `automatic wallet backup marks failure when Paykit snapshot fails`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) whenever { privatePaykitRepo.backupSnapshot() } .thenReturn(Result.failure(BackupRepoTestError("paykit session missing capabilities"))) val backupStatuses = MutableStateFlow( @@ -134,8 +135,17 @@ class BackupRepoTest : BaseUnitTest() { advanceTimeBy(5_000) runCurrent() + verify(privatePaykitRepo).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + val status = backupStatuses.value.getValue(BackupCategory.WALLET) + assertTrue(status.isRequired) + assertFalse(status.running) + + advanceTimeBy(5_000) + runCurrent() + + verify(privatePaykitRepo).backupSnapshot() verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) - assertFalse(backupStatuses.value.getValue(BackupCategory.WALLET).running) } finally { sut.stopObservingBackups() } From 0f9b34b38cc8e9d4575cf47d6d52790478f279fd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 22 Jul 2026 07:45:57 -0300 Subject: [PATCH 5/5] refactor: code cleanup --- .../java/to/bitkit/repositories/BackupRepo.kt | 22 +++++++------------ .../to/bitkit/repositories/BackupRepoTest.kt | 3 ++- changelog.d/next/1092.fixed.md | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 817e31641..9f92ef4fc 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -4,7 +4,6 @@ import android.content.Context import com.synonym.bitkitcore.migrateBackupActivitiesJson import com.synonym.bitkitcore.migrateBackupActivityTagsJson import com.synonym.bitkitcore.migrateBackupPreActivityMetadataJson -import com.synonym.vssclient.VssItem import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview @@ -426,7 +425,7 @@ class BackupRepo @Inject constructor( } } - suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { + suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) val backupRequired = currentTimeMillis() @@ -437,8 +436,10 @@ class BackupRepo @Inject constructor( } val data = runSuspendCatching { getBackupDataBytes(category) } - .onFailure { markBackupFailed(category, backupRequired, it) } - .getOrElse { return@withContext Result.failure(it) } + .getOrElse { + markBackupFailed(category, backupRequired, it) + return@withContext Result.failure(it) + } vssBackupClient.putObject(key = category.name, data = data) .onSuccess { @@ -453,6 +454,7 @@ class BackupRepo @Inject constructor( Logger.info("Backup succeeded for: '$category'", context = TAG) } .onFailure { markBackupFailed(category, backupRequired, it) } + .map {} } private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { @@ -541,16 +543,8 @@ class BackupRepo @Inject constructor( private suspend fun getWalletBackupDataBytes(): ByteArray { val transfers = db.transferDao().getAll() - val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot() - .onFailure { - Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG) - } - .getOrThrow() - val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot() - .onFailure { - Logger.warn("Failed to snapshot Paykit SDK state", it, context = TAG) - } - .getOrThrow() + val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() + val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow() val payload = WalletBackupV1( createdAt = currentTimeMillis(), diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 65e71d42a..0eaecc6cb 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -144,7 +144,8 @@ class BackupRepoTest : BaseUnitTest() { advanceTimeBy(5_000) runCurrent() - verify(privatePaykitRepo).backupSnapshot() + // still one attempt: the failed timestamp suppresses a retry until data changes again + verify(privatePaykitRepo, times(1)).backupSnapshot() verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } finally { sut.stopObservingBackups() diff --git a/changelog.d/next/1092.fixed.md b/changelog.d/next/1092.fixed.md index b4fc5af7a..c6f9340fa 100644 --- a/changelog.d/next/1092.fixed.md +++ b/changelog.d/next/1092.fixed.md @@ -1 +1 @@ -Fixed a crash during automatic wallet backup when Paykit state could not be read. +Fixed the wallet backup failing repeatedly when Paykit state could not be read.