From c95160a6b19ab133c3024dd297fcc64f3b490402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Mon, 17 Aug 2026 10:17:26 -0300 Subject: [PATCH 01/12] feat: remove wallet scope filter --- app/src/main/java/to/bitkit/repositories/ActivityRepo.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 73375d5f0..13ff8059d 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -824,12 +824,14 @@ class ActivityRepo @Inject constructor( } /** - * Get all [ActivityTags] for backup + * Get all [ActivityTags] for backup, including hardware wallet scopes. + * + * Hardware wallet activities are rebuilt from the device watcher on every reconnect, but their tags + * are user authored and cannot be re-derived, so every wallet scope is backed up. */ suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { runCatching { coreService.activity.getAllActivitiesTags() - .filter { it.walletId == WalletScope.default } }.onFailure { Logger.error("getAllActivityTags error", it, context = TAG) } From 483387ca896d524c081497571e3f122faedf9267 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 10:29:40 -0300 Subject: [PATCH 02/12] refactor: extract HW migration methods to a service, improving testability --- .../to/bitkit/repositories/ActivityRepo.kt | 19 +++++++++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 24 ++++++++++--------- .../repositories/PreActivityMetadataRepo.kt | 11 +++++++++ .../java/to/bitkit/services/CoreService.kt | 12 ++++++++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 13ff8059d..75332164f 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -837,6 +837,25 @@ class ActivityRepo @Inject constructor( } } + /** + * Fill in wallet ids missing from a backup envelope's `activities` slice, letting Core migrate its own + * model JSON before the app decodes it. + */ + suspend fun migrateBackupActivitiesJson(json: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + coreService.activity.migrateBackupActivitiesJson(json) + } + } + + /** + * Fill in wallet ids missing from a backup envelope's `activityTags` slice. + */ + suspend fun migrateBackupActivityTagsJson(json: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + coreService.activity.migrateBackupActivityTagsJson(json) + } + } + suspend fun getWalletIds(): Result> = withContext(bgDispatcher) { runSuspendCatching { coreService.activity.getWalletIds() diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 719821704..2bda2bd81 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -1,9 +1,6 @@ package to.bitkit.repositories import android.content.Context -import com.synonym.bitkitcore.migrateBackupActivitiesJson -import com.synonym.bitkitcore.migrateBackupActivityTagsJson -import com.synonym.bitkitcore.migrateBackupPreActivityMetadataJson import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview @@ -584,7 +581,7 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.METADATA) { dataBytes -> val migrated = migrateCoreOwnedBackupFields( String(dataBytes), - mapOf("tagMetadata" to ::migrateBackupPreActivityMetadataJson), + mapOf("tagMetadata" to preActivityMetadataRepo::migrateBackupPreActivityMetadataJson), ) val parsed = json.decodeFromString(migrated) val cleanCache = parsed.cache.resetBip21() // Force address rotation @@ -625,8 +622,8 @@ class BackupRepo @Inject constructor( val migrated = migrateCoreOwnedBackupFields( String(dataBytes), mapOf( - "activities" to ::migrateBackupActivitiesJson, - "activityTags" to ::migrateBackupActivityTagsJson, + "activities" to activityRepo::migrateBackupActivitiesJson, + "activityTags" to activityRepo::migrateBackupActivityTagsJson, ), ) val parsed = json.decodeFromString(migrated) @@ -714,18 +711,23 @@ class BackupRepo @Inject constructor( * Core migration helper as raw JSON, so the app never edits Core model JSON * itself. Records that already carry a wallet id are left unchanged, so this * is safe to run on current backups too. + * + * A field whose migration fails keeps its original JSON, so a Core failure degrades to the + * pre-migration behaviour instead of losing the whole category. */ - private fun migrateCoreOwnedBackupFields( + private suspend fun migrateCoreOwnedBackupFields( raw: String, - fieldMigrations: Map String>, + fieldMigrations: Map Result>, ): String { val root = json.parseToJsonElement(raw).jsonObject val patched = root.toMutableMap() for ((field, migrate) in fieldMigrations) { val element = root[field] - if (element is JsonArray) { - patched[field] = json.parseToJsonElement(migrate(element.toString())) - } + if (element !is JsonArray) continue + + migrate(element.toString()) + .onSuccess { patched[field] = json.parseToJsonElement(it) } + .onFailure { Logger.warn("Failed to migrate backup field '$field'", it, context = TAG) } } return JsonObject(patched).toString() } diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index a5c60d01b..84f458674 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.services.CoreService import to.bitkit.models.WalletScope import to.bitkit.utils.Logger @@ -47,6 +48,16 @@ class PreActivityMetadataRepo @Inject constructor( } } + /** + * Fill in wallet ids missing from a backup envelope's `tagMetadata` slice, letting Core migrate its own + * model JSON before the app decodes it. + */ + suspend fun migrateBackupPreActivityMetadataJson(json: String): Result = withContext(ioDispatcher) { + return@withContext runSuspendCatching { + coreService.activity.migrateBackupPreActivityMetadataJson(json) + } + } + suspend fun addPreActivityMetadata(metadata: PreActivityMetadata): Result = withContext(ioDispatcher) { return@withContext runCatching { coreService.activity.addPreActivityMetadata(metadata) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index caaa68a76..b59193f7e 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -587,6 +587,18 @@ class ActivityService( getAllClosedChannels(sortDirection) } + suspend fun migrateBackupActivitiesJson(json: String): String = ServiceQueue.CORE.background { + com.synonym.bitkitcore.migrateBackupActivitiesJson(json) + } + + suspend fun migrateBackupActivityTagsJson(json: String): String = ServiceQueue.CORE.background { + com.synonym.bitkitcore.migrateBackupActivityTagsJson(json) + } + + suspend fun migrateBackupPreActivityMetadataJson(json: String): String = ServiceQueue.CORE.background { + com.synonym.bitkitcore.migrateBackupPreActivityMetadataJson(json) + } + suspend fun handlePaymentEvent(paymentHash: String) = ServiceQueue.CORE.background { val payments = lightningService.listPayments() ?: run { Logger.warn("No payments available for hash $paymentHash", context = TAG) From a61bc445e0a51b0afc76ec3f05e1b9a4a6a80821 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 10:53:05 -0300 Subject: [PATCH 03/12] feat: change detection and conditional VSS rewrite --- .../java/to/bitkit/repositories/BackupRepo.kt | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2bda2bd81..88c6ada65 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -577,18 +577,25 @@ class BackupRepo @Inject constructor( _isRestoring.update { true } + // Mutated only by the sequential restore steps below, inside this single coroutine. + val categoriesNeedingRewrite = mutableSetOf() + val result = runCatching { performRestore(BackupCategory.METADATA) { dataBytes -> - val migrated = migrateCoreOwnedBackupFields( + val migration = migrateCoreOwnedBackupFields( String(dataBytes), mapOf("tagMetadata" to preActivityMetadataRepo::migrateBackupPreActivityMetadataJson), ) - val parsed = json.decodeFromString(migrated) + val parsed = json.decodeFromString(migration.json) val cleanCache = parsed.cache.resetBip21() // Force address rotation cacheStore.update { cleanCache } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() - preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata).getOrNull() + // Only rewrite once Core holds the migrated rows, otherwise the rewrite would replace the + // legacy backup with whatever Core happens to have. + preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata) + .onSuccess { if (migration.changed) categoriesNeedingRewrite += BackupCategory.METADATA } + .onFailure { Logger.warn("Failed to restore pre-activity metadata", it, context = TAG) } pubkyRepo.restoreSessionBackupState(parsed.pubkySession) .onFailure { Logger.warn("Failed to restore pubky session backup state", it, context = TAG) @@ -619,15 +626,17 @@ class BackupRepo @Inject constructor( parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> - val migrated = migrateCoreOwnedBackupFields( + val migration = migrateCoreOwnedBackupFields( String(dataBytes), mapOf( "activities" to activityRepo::migrateBackupActivitiesJson, "activityTags" to activityRepo::migrateBackupActivityTagsJson, ), ) - val parsed = json.decodeFromString(migrated) + val parsed = json.decodeFromString(migration.json) activityRepo.restoreFromBackup(parsed) + .onSuccess { if (migration.changed) categoriesNeedingRewrite += BackupCategory.ACTIVITY } + .onFailure { Logger.warn("Failed to restore activity backup", it, context = TAG) } parsed.createdAt } @@ -640,6 +649,10 @@ class BackupRepo @Inject constructor( _isRestoring.update { false } + if (result.isSuccess) { + rewriteMigratedBackups(categoriesNeedingRewrite) + } + return@withContext result } @@ -718,18 +731,41 @@ class BackupRepo @Inject constructor( private suspend fun migrateCoreOwnedBackupFields( raw: String, fieldMigrations: Map Result>, - ): String { + ): CoreFieldMigration { val root = json.parseToJsonElement(raw).jsonObject val patched = root.toMutableMap() + var changed = false + for ((field, migrate) in fieldMigrations) { val element = root[field] if (element !is JsonArray) continue migrate(element.toString()) - .onSuccess { patched[field] = json.parseToJsonElement(it) } + .onSuccess { migratedJson -> + // Compare elements, not strings: Core's serializer may reorder keys or reformat + // without changing any value. + val migratedElement = json.parseToJsonElement(migratedJson) + if (migratedElement == element) return@onSuccess + + patched[field] = migratedElement + changed = true + Logger.debug("Migrated backup field '$field' to current wallet scope", context = TAG) + } .onFailure { Logger.warn("Failed to migrate backup field '$field'", it, context = TAG) } } - return JsonObject(patched).toString() + + return CoreFieldMigration(json = JsonObject(patched).toString(), changed = changed) + } + + /** + * Re-upload the app-owned VSS envelopes whose Core-owned fields were migrated on restore, so future + * restores decode current wallet-scoped entries without the legacy migration path. + */ + private suspend fun rewriteMigratedBackups(categories: Set) { + if (categories.isEmpty()) return + + Logger.info("Rewriting migrated backups for: '${categories.joinToString()}'", context = TAG) + categories.forEach { triggerBackup(it) } } private suspend fun performRestore( @@ -768,3 +804,15 @@ class BackupRepo @Inject constructor( private val VSS_TIMESTAMP_TIMEOUT = 60.seconds } } + +/** + * Result of handing a backup envelope's Core-owned fields to Core for migration. + * + * @param json the envelope with every successfully migrated field replaced. + * @param changed whether any field actually differed, meaning the envelope predates wallet-scoped + * activity data and its VSS backup should be rewritten. + */ +private data class CoreFieldMigration( + val json: String, + val changed: Boolean, +) From 830a4579495aa1e2b7d1cfe7a88fdb4addf632f5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 11:17:08 -0300 Subject: [PATCH 04/12] test: update tests with HW backup scope --- .../bitkit/repositories/ActivityRepoTest.kt | 7 +- .../to/bitkit/repositories/BackupRepoTest.kt | 214 ++++++++++++++++++ 2 files changed, 218 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index f560fb88b..bbb781311 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -769,15 +769,16 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `getAllActivitiesTags returns only default wallet tags`() = test { + fun `getAllActivitiesTags returns tags for every wallet scope`() = test { val defaultTags = ActivityTags(WalletScope.default, "default-activity", listOf("daily")) - val hardwareTags = ActivityTags("hardware-wallet", "hardware-activity", listOf("cold")) + val hardwareTags = ActivityTags("trezor:abc123", "hardware-activity", listOf("cold")) whenever { coreService.activity.getAllActivitiesTags() } .thenReturn(listOf(defaultTags, hardwareTags)) val result = sut.getAllActivitiesTags() - assertEquals(listOf(defaultTags), result.getOrThrow()) + // Hardware wallet tags are user authored and cannot be re-derived, so they must reach the backup. + assertEquals(listOf(defaultTags, hardwareTags), result.getOrThrow()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 1ea56a51c..81e867f46 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -1,6 +1,8 @@ package to.bitkit.repositories import android.content.Context +import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.vssclient.VssItem import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -9,6 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -38,9 +42,12 @@ import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.json +import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus +import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WalletScope import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.services.LightningService @@ -370,6 +377,202 @@ class BackupRepoTest : BaseUnitTest() { verify(settingsStore, never()).update(any()) } + @Test + fun `legacy activity backup is migrated by core before decode`() = test { + stubWalletBackup() + stubActivityRestore() + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(activityRepo) { migrateBackupActivitiesJson(LEGACY_ACTIVITIES_JSON) } + verifyBlocking(activityRepo) { migrateBackupActivityTagsJson(LEGACY_TAGS_JSON) } + + val payloadCaptor = argumentCaptor() + verifyBlocking(activityRepo) { restoreFromBackup(payloadCaptor.capture()) } + assertEquals(listOf(defaultTag()), payloadCaptor.firstValue.activityTags) + } + + @Test + fun `legacy metadata backup is migrated by core before decode`() = test { + stubWalletBackup() + stubMetadataRestore() + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(preActivityMetadataRepo) { migrateBackupPreActivityMetadataJson(LEGACY_METADATA_JSON) } + + val metadataCaptor = argumentCaptor>() + verifyBlocking(preActivityMetadataRepo) { upsertPreActivityMetadata(metadataCaptor.capture()) } + assertEquals(listOf(preActivityMetadata()), metadataCaptor.firstValue) + } + + @Test + fun `migrated backups are rewritten to vss after restore`() = test { + stubWalletBackup() + stubActivityRestore() + stubMetadataRestore() + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.ACTIVITY.name), any()) } + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.METADATA.name), any()) } + } + + @Test + fun `current backups are not rewritten after restore`() = test { + stubWalletBackup() + // Envelopes already carry wallet ids, so Core hands the slices back untouched. + stubActivityRestore(envelope = activityEnvelope(tags = listOf(defaultTag()))) + stubMetadataRestore(envelope = metadataEnvelope(metadata = listOf(preActivityMetadata()))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `rewritten activity backup preserves hardware wallet tags`() = test { + stubWalletBackup() + val hardwareTag = ActivityTags(walletId = HARDWARE_WALLET_ID, activityId = "a2", tags = listOf("hw")) + stubActivityRestore(backedUpTags = listOf(defaultTag(), hardwareTag)) + val dataCaptor = argumentCaptor() + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(vssBackupClient) { + putObject(eq(BackupCategory.ACTIVITY.name), dataCaptor.capture()) + } + val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) + assertEquals(listOf(defaultTag(), hardwareTag), payload.activityTags) + } + + @Test + fun `activity backup is not rewritten when core restore fails`() = test { + stubWalletBackup() + stubActivityRestore() + whenever { activityRepo.restoreFromBackup(any()) } + .thenReturn(Result.failure(BackupRepoTestError("upsert failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + // Rewriting here would replace the legacy backup with whatever Core happens to hold. + verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) + } + + @Test + fun `failed core migration neither fails the restore nor rewrites the backup`() = test { + stubWalletBackup() + stubActivityRestore() + whenever { activityRepo.migrateBackupActivityTagsJson(any()) } + .thenReturn(Result.failure(BackupRepoTestError("core migration failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) + } + + private fun stubActivityRestore( + envelope: String = legacyActivityEnvelope(), + migratedTagsJson: String = json.encodeToString(listOf(defaultTag())), + backedUpTags: List = listOf(defaultTag()), + ) { + stubRestoreEnvelope(BackupCategory.ACTIVITY, envelope) + whenever { activityRepo.migrateBackupActivitiesJson(any()) }.thenReturn(Result.success("[]")) + whenever { activityRepo.migrateBackupActivityTagsJson(any()) }.thenReturn(Result.success(migratedTagsJson)) + whenever { activityRepo.restoreFromBackup(any()) }.thenReturn(Result.success(Unit)) + // Read back by the rewrite through getBackupDataBytes(ACTIVITY). + whenever { activityRepo.getActivities() }.thenReturn(Result.success(emptyList())) + whenever { activityRepo.getClosedChannels() }.thenReturn(Result.success(emptyList())) + whenever { activityRepo.getAllActivitiesTags() }.thenReturn(Result.success(backedUpTags)) + } + + private fun stubMetadataRestore( + envelope: String = legacyMetadataEnvelope(), + migratedMetadataJson: String = json.encodeToString(listOf(preActivityMetadata())), + restorableMetadata: List = listOf(preActivityMetadata()), + ) { + stubRestoreEnvelope(BackupCategory.METADATA, envelope) + whenever { preActivityMetadataRepo.migrateBackupPreActivityMetadataJson(any()) } + .thenReturn(Result.success(migratedMetadataJson)) + whenever { preActivityMetadataRepo.upsertPreActivityMetadata(any()) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.restoreSessionBackupState(anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.restoreContactProfileOverrides(anyOrNull()) }.thenReturn(Result.success(Unit)) + // Read back by the rewrite through getMetadataBackupDataBytes(). + whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } + .thenReturn(Result.success(restorableMetadata)) + whenever { pubkyRepo.snapshotSessionBackupState() }.thenReturn(Result.success(null)) + whenever { pubkyRepo.snapshotContactProfileOverrides() }.thenReturn(Result.success(null)) + } + + private fun stubRestoreEnvelope(category: BackupCategory, envelope: String) { + whenever { vssBackupClient.getObject(category.name) }.thenReturn( + Result.success( + VssItem(key = category.name, value = envelope.toByteArray(), version = 1) + ) + ) + } + + /** An `ActivityBackupV1` whose Core-owned tag records predate `walletId`. */ + private fun legacyActivityEnvelope(): String = envelopeWithRawField( + base = activityEnvelope(), + field = "activityTags", + rawJson = LEGACY_TAGS_JSON, + ) + + /** A `MetadataBackupV1` whose Core-owned metadata records predate `walletId`. */ + private fun legacyMetadataEnvelope(): String = envelopeWithRawField( + base = metadataEnvelope(), + field = "tagMetadata", + rawJson = LEGACY_METADATA_JSON, + ) + + private fun activityEnvelope(tags: List = emptyList()) = json.encodeToString( + ActivityBackupV1( + createdAt = 123, + activities = emptyList(), + activityTags = tags, + closedChannels = emptyList(), + ) + ) + + private fun metadataEnvelope(metadata: List = emptyList()) = json.encodeToString( + MetadataBackupV1(createdAt = 123, tagMetadata = metadata, cache = AppCacheData()) + ) + + private fun envelopeWithRawField(base: String, field: String, rawJson: String): String { + val patched = json.parseToJsonElement(base).jsonObject.toMutableMap() + patched[field] = json.parseToJsonElement(rawJson) + return JsonObject(patched).toString() + } + + private fun defaultTag() = ActivityTags( + walletId = WalletScope.default, + activityId = "a1", + tags = listOf("coffee"), + ) + + private fun preActivityMetadata() = PreActivityMetadata( + walletId = WalletScope.default, + paymentId = "p1", + tags = listOf("coffee"), + paymentHash = null, + txId = null, + address = null, + isReceive = true, + feeRate = 1uL, + isTransfer = false, + channelId = null, + createdAt = 1uL, + ) + private fun stubWalletBackup( paykitSdkBackupState: String? = null, watchOnlyAccounts: List? = null, @@ -451,6 +654,17 @@ class BackupRepoTest : BaseUnitTest() { private class BackupRepoTestError(message: String) : AppError(message) + private companion object { + const val HARDWARE_WALLET_ID = "trezor:abc123" + + /** Core-owned slices as written before `walletId` existed. */ + const val LEGACY_ACTIVITIES_JSON = "[]" + const val LEGACY_TAGS_JSON = """[{"activityId":"a1","tags":["coffee"]}]""" + const val LEGACY_METADATA_JSON = + """[{"paymentId":"p1","tags":["coffee"],"isReceive":true,"feeRate":1,""" + + """"isTransfer":false,"createdAt":1}]""" + } + private fun watchOnlyAccount() = WatchOnlyAccountRecord( id = "account-7", walletIndex = 0, From 8003ddbf236deab7dbeb3dd67c60d63fab42c0bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 11:22:12 -0300 Subject: [PATCH 05/12] docs: add changelog for hw backup restore --- changelog.d/next/1046.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1046.fixed.md diff --git a/changelog.d/next/1046.fixed.md b/changelog.d/next/1046.fixed.md new file mode 100644 index 000000000..6af14dc8e --- /dev/null +++ b/changelog.d/next/1046.fixed.md @@ -0,0 +1 @@ +Tags you add to hardware wallet activity are now included in your backup, and older backups are upgraded automatically the first time you restore them. From ab81164400b6487c0c7acee2d712695ca6751b23 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 11:31:54 -0300 Subject: [PATCH 06/12] refactor: extract metadata and activities restore methods --- .../java/to/bitkit/repositories/BackupRepo.kt | 101 +++++++++++------- .../repositories/PreActivityMetadataRepo.kt | 2 +- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 88c6ada65..68a2981d5 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -78,7 +78,7 @@ import kotlin.time.ExperimentalTime * Idle State: running=false, synced≥required * ``` */ -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @OptIn(ExperimentalTime::class) @Singleton class BackupRepo @Inject constructor( @@ -582,30 +582,9 @@ class BackupRepo @Inject constructor( val result = runCatching { performRestore(BackupCategory.METADATA) { dataBytes -> - val migration = migrateCoreOwnedBackupFields( - String(dataBytes), - mapOf("tagMetadata" to preActivityMetadataRepo::migrateBackupPreActivityMetadataJson), - ) - val parsed = json.decodeFromString(migration.json) - val cleanCache = parsed.cache.resetBip21() // Force address rotation - cacheStore.update { cleanCache } - Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) - onCacheRestored() - // Only rewrite once Core holds the migrated rows, otherwise the rewrite would replace the - // legacy backup with whatever Core happens to have. - preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata) - .onSuccess { if (migration.changed) categoriesNeedingRewrite += BackupCategory.METADATA } - .onFailure { Logger.warn("Failed to restore pre-activity metadata", it, context = TAG) } - pubkyRepo.restoreSessionBackupState(parsed.pubkySession) - .onFailure { - Logger.warn("Failed to restore pubky session backup state", it, context = TAG) - } - pubkyRepo.restoreContactProfileOverrides(parsed.pubkyContactProfileOverrides) - .onFailure { - Logger.warn("Failed to restore pubky contact profile overrides", it, context = TAG) - } - Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) - parsed.createdAt + val restored = restoreMetadataBackup(dataBytes, onCacheRestored) + if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.METADATA + restored.createdAt } performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) @@ -626,18 +605,9 @@ class BackupRepo @Inject constructor( parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> - val migration = migrateCoreOwnedBackupFields( - String(dataBytes), - mapOf( - "activities" to activityRepo::migrateBackupActivitiesJson, - "activityTags" to activityRepo::migrateBackupActivityTagsJson, - ), - ) - val parsed = json.decodeFromString(migration.json) - activityRepo.restoreFromBackup(parsed) - .onSuccess { if (migration.changed) categoriesNeedingRewrite += BackupCategory.ACTIVITY } - .onFailure { Logger.warn("Failed to restore activity backup", it, context = TAG) } - parsed.createdAt + val restored = restoreActivityBackup(dataBytes) + if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY + restored.createdAt } Logger.info("Full restore success", context = TAG) @@ -656,6 +626,51 @@ class BackupRepo @Inject constructor( return@withContext result } + private suspend fun restoreMetadataBackup( + dataBytes: ByteArray, + onCacheRestored: suspend () -> Unit, + ): RestoredCoreBackup { + val migration = migrateCoreOwnedBackupFields( + String(dataBytes), + mapOf("tagMetadata" to preActivityMetadataRepo::migrateBackupPreActivityMetadataJson), + ) + val parsed = json.decodeFromString(migration.json) + val cleanCache = parsed.cache.resetBip21() // Force address rotation + cacheStore.update { cleanCache } + Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) + onCacheRestored() + val persisted = preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata) + .onFailure { Logger.warn("Failed to restore pre-activity metadata", it, context = TAG) } + .isSuccess + pubkyRepo.restoreSessionBackupState(parsed.pubkySession) + .onFailure { + Logger.warn("Failed to restore pubky session backup state", it, context = TAG) + } + pubkyRepo.restoreContactProfileOverrides(parsed.pubkyContactProfileOverrides) + .onFailure { + Logger.warn("Failed to restore pubky contact profile overrides", it, context = TAG) + } + Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) + + return RestoredCoreBackup(createdAt = parsed.createdAt, needsRewrite = migration.changed && persisted) + } + + private suspend fun restoreActivityBackup(dataBytes: ByteArray): RestoredCoreBackup { + val migration = migrateCoreOwnedBackupFields( + String(dataBytes), + mapOf( + "activities" to activityRepo::migrateBackupActivitiesJson, + "activityTags" to activityRepo::migrateBackupActivityTagsJson, + ), + ) + val parsed = json.decodeFromString(migration.json) + val persisted = activityRepo.restoreFromBackup(parsed) + .onFailure { Logger.warn("Failed to restore activity backup", it, context = TAG) } + .isSuccess + + return RestoredCoreBackup(createdAt = parsed.createdAt, needsRewrite = migration.changed && persisted) + } + private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) @@ -816,3 +831,15 @@ private data class CoreFieldMigration( val json: String, val changed: Boolean, ) + +/** + * Outcome of restoring a backup category that embeds Core-owned data. + * + * @param createdAt the restored envelope's timestamp, used as the category's synced marker. + * @param needsRewrite whether the envelope was migrated **and** Core persisted the result, meaning its VSS + * backup can safely be rewritten with current entries. + */ +private data class RestoredCoreBackup( + val createdAt: Long, + val needsRewrite: Boolean, +) diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index 84f458674..5821f2c18 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -11,8 +11,8 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching -import to.bitkit.services.CoreService import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton From 251b47379d06ad17dbb5fec7e8a5222e8b8e9646 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 17 Aug 2026 11:33:58 -0300 Subject: [PATCH 07/12] chore: rename changelog fragment --- changelog.d/next/{1046.fixed.md => 1163.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1046.fixed.md => 1163.fixed.md} (100%) diff --git a/changelog.d/next/1046.fixed.md b/changelog.d/next/1163.fixed.md similarity index 100% rename from changelog.d/next/1046.fixed.md rename to changelog.d/next/1163.fixed.md From 2b0b2f09702dea5b32f173e15d483f59120cf4cb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 18 Aug 2026 07:33:32 -0300 Subject: [PATCH 08/12] fix: save HW tags as PreActivityMetadata, so they can travel in metadata backup --- .../to/bitkit/repositories/ActivityRepo.kt | 75 ++++++++++++++- .../java/to/bitkit/repositories/BackupRepo.kt | 9 +- .../bitkit/repositories/ActivityRepoTest.kt | 96 ++++++++++++++++++- .../to/bitkit/repositories/BackupRepoTest.kt | 39 ++++++-- 4 files changed, 204 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 75332164f..bd75551a7 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -11,6 +11,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -824,19 +825,64 @@ class ActivityRepo @Inject constructor( } /** - * Get all [ActivityTags] for backup, including hardware wallet scopes. + * Get all [ActivityTags] for backup. * - * Hardware wallet activities are rebuilt from the device watcher on every reconnect, but their tags - * are user authored and cannot be re-derived, so every wallet scope is backed up. + * Scoped to the default wallet: hardware activities are rebuilt by the device watcher and are not + * backed up, so a restored hardware [ActivityTags] row would have no parent activity and Core's + * foreign key would reject it. Hardware tags travel as [PreActivityMetadata] instead, see + * [getHardwareTagsAsPreActivityMetadata]. */ suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { runCatching { coreService.activity.getAllActivitiesTags() + .filter { it.walletId == WalletScope.default } }.onFailure { Logger.error("getAllActivityTags error", it, context = TAG) } } + /** + * Hardware wallet tags rendered as [PreActivityMetadata] so they can travel in the metadata backup. + * + * Hardware activities are rebuilt by the device watcher and are deliberately not backed up, so a + * restored hardware [ActivityTags] row would reference a missing activity. Core re-attaches + * pre-activity metadata when the watcher recreates the activity, matching received activities on + * address and sent activities on payment id, so the tags land back on the right rows. + * + * Every other field is left neutral: Core copies `address`, `feeRate`, `isTransfer` and `channelId` + * onto the activity it attaches to, and only when they are set, so a tag-only record must not carry + * them. + */ + suspend fun getHardwareTagsAsPreActivityMetadata(): Result> = + withContext(bgDispatcher) { + runSuspendCatching { + val hardwareTags = coreService.activity.getAllActivitiesTags() + .filter { it.walletId != WalletScope.default } + if (hardwareTags.isEmpty()) return@runSuspendCatching emptyList() + + val onchainByScopedId = coreService.activity.get( + walletId = null, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + .filterIsInstance() + .associateBy { it.v1.walletId to it.v1.id } + + hardwareTags.mapNotNull { tag -> + val activity = onchainByScopedId[tag.walletId to tag.activityId] ?: return@mapNotNull null + activity.v1.toPreActivityMetadata(tag.tags) + } + }.onFailure { + Logger.error("getHardwareTagsAsPreActivityMetadata error", it, context = TAG) + } + } + /** * Fill in wallet ids missing from a backup envelope's `activities` slice, letting Core migrate its own * model JSON before the app decodes it. @@ -928,3 +974,26 @@ class ActivityRepo @Inject constructor( data class ActivityState( val tags: ImmutableList = persistentListOf(), ) + +/** + * Renders an on-chain activity's tags as a [PreActivityMetadata] Core can re-attach later. + * + * The lookup key mirrors Core: received activities are matched on `address` with `isReceive` set, sent + * activities on `paymentId`. + */ +private fun OnchainActivity.toPreActivityMetadata(tags: List): PreActivityMetadata { + val isReceive = txType == PaymentType.RECEIVED + return PreActivityMetadata( + walletId = walletId, + paymentId = if (isReceive) id else txId, + tags = tags, + paymentHash = null, + txId = txId, + address = address.takeIf { isReceive }, + isReceive = isReceive, + feeRate = 0uL, + isTransfer = false, + channelId = null, + createdAt = timestamp, + ) +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 68a2981d5..70fc900f3 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -313,6 +313,8 @@ class BackupRepo @Inject constructor( // ACTIVITY - Observe activity changes dataListenerJobs.add(observeBackupChanges(activityRepo.activitiesChanged, BackupCategory.ACTIVITY)) + // Hardware tags are carried by the metadata backup, and tagging emits activitiesChanged. + dataListenerJobs.add(observeBackupChanges(activityRepo.activitiesChanged, BackupCategory.METADATA)) // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) @@ -537,13 +539,18 @@ class BackupRepo @Inject constructor( private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) + // Hardware tags ride here rather than in the activity backup: their activities are rebuilt by the + // device watcher, so Core re-attaches them once the watcher recreates the rows. + val hardwareTagMetadata = activityRepo.getHardwareTagsAsPreActivityMetadata().getOrDefault(emptyList()) + val tagMetadata = (preActivityMetadata + hardwareTagMetadata) + .distinctBy { it.walletId to it.paymentId } val cacheData = cacheStore.data.first() val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), - tagMetadata = preActivityMetadata, + tagMetadata = tagMetadata, cache = cacheData, pubkySession = pubkySession, pubkyContactProfileOverrides = pubkyContactProfileOverrides, diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index bbb781311..8cc08838f 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -769,18 +769,102 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `getAllActivitiesTags returns tags for every wallet scope`() = test { + fun `getAllActivitiesTags returns only default wallet tags`() = test { val defaultTags = ActivityTags(WalletScope.default, "default-activity", listOf("daily")) - val hardwareTags = ActivityTags("trezor:abc123", "hardware-activity", listOf("cold")) + val hardwareTags = ActivityTags(HARDWARE_WALLET_ID, "hardware-activity", listOf("cold")) whenever { coreService.activity.getAllActivitiesTags() } .thenReturn(listOf(defaultTags, hardwareTags)) val result = sut.getAllActivitiesTags() - // Hardware wallet tags are user authored and cannot be re-derived, so they must reach the backup. - assertEquals(listOf(defaultTags, hardwareTags), result.getOrThrow()) + // Hardware tags would have no parent activity on restore, they travel as pre-activity metadata. + assertEquals(listOf(defaultTags), result.getOrThrow()) } + @Test + fun `getHardwareTagsAsPreActivityMetadata keys a received activity by address`() = test { + stubHardwareTagLookup(hardwareOnchainActivity(txType = PaymentType.RECEIVED)) + + val result = sut.getHardwareTagsAsPreActivityMetadata().getOrThrow() + + val metadata = result.single() + assertEquals(HARDWARE_WALLET_ID, metadata.walletId) + assertEquals("hw-activity", metadata.paymentId) + assertEquals("bcrt1qhw", metadata.address) + assertTrue(metadata.isReceive) + assertEquals(listOf("cold"), metadata.tags) + // Core copies these onto the activity it attaches to, so a tag-only record must leave them unset. + assertEquals(0uL, metadata.feeRate) + assertFalse(metadata.isTransfer) + assertNull(metadata.channelId) + } + + @Test + fun `getHardwareTagsAsPreActivityMetadata keys a sent activity by payment id`() = test { + stubHardwareTagLookup(hardwareOnchainActivity(txType = PaymentType.SENT)) + + val result = sut.getHardwareTagsAsPreActivityMetadata().getOrThrow() + + val metadata = result.single() + assertEquals("hw-txid", metadata.paymentId) + assertNull(metadata.address) + assertFalse(metadata.isReceive) + } + + @Test + fun `getHardwareTagsAsPreActivityMetadata ignores default wallet tags`() = test { + whenever { coreService.activity.getAllActivitiesTags() } + .thenReturn(listOf(ActivityTags(WalletScope.default, "default-activity", listOf("daily")))) + + val result = sut.getHardwareTagsAsPreActivityMetadata().getOrThrow() + + assertEquals(emptyList(), result) + } + + private suspend fun stubHardwareTagLookup(activity: Activity.Onchain) { + whenever { coreService.activity.getAllActivitiesTags() } + .thenReturn(listOf(ActivityTags(HARDWARE_WALLET_ID, "hw-activity", listOf("cold")))) + whenever { + coreService.activity.get( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(listOf(activity)) + } + + private fun hardwareOnchainActivity(txType: PaymentType) = Activity.Onchain( + OnchainActivity( + walletId = HARDWARE_WALLET_ID, + id = "hw-activity", + txType = txType, + txId = "hw-txid", + value = 1000uL, + fee = 1uL, + feeRate = 1uL, + address = "bcrt1qhw", + confirmed = true, + timestamp = 123uL, + isBoosted = false, + boostTxIds = emptyList(), + isTransfer = false, + doesExist = true, + confirmTimestamp = null, + channelId = null, + transferTxId = null, + contact = null, + createdAt = null, + updatedAt = null, + seenAt = null, + ) + ) + @Test fun `removeAllActivities removes all activities successfully`() = test { wheneverBlocking { coreService.activity.removeAll() }.thenReturn(Unit) @@ -1087,4 +1171,8 @@ class ActivityRepoTest : BaseUnitTest() { // Verify pending boost was removed (skipped) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } + + private companion object { + const val HARDWARE_WALLET_ID = "trezor:abc123" + } } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 81e867f46..2d1700217 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -436,20 +436,44 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `rewritten activity backup preserves hardware wallet tags`() = test { - stubWalletBackup() - val hardwareTag = ActivityTags(walletId = HARDWARE_WALLET_ID, activityId = "a2", tags = listOf("hw")) - stubActivityRestore(backedUpTags = listOf(defaultTag(), hardwareTag)) + fun `metadata backup carries hardware tags as pre-activity metadata`() = test { + val hardwareTagMetadata = preActivityMetadata().copy( + walletId = HARDWARE_WALLET_ID, + paymentId = "hw-txid", + tags = listOf("hw"), + ) + whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } + .thenReturn(Result.success(listOf(preActivityMetadata()))) + whenever { activityRepo.getHardwareTagsAsPreActivityMetadata() } + .thenReturn(Result.success(listOf(hardwareTagMetadata))) + whenever { pubkyRepo.snapshotSessionBackupState() }.thenReturn(Result.success(null)) + whenever { pubkyRepo.snapshotContactProfileOverrides() }.thenReturn(Result.success(null)) val dataCaptor = argumentCaptor() - val result = sut.performFullRestoreFromLatestBackup() + sut.triggerBackup(BackupCategory.METADATA) + + verifyBlocking(vssBackupClient) { + putObject(eq(BackupCategory.METADATA.name), dataCaptor.capture()) + } + val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) + assertEquals(listOf(preActivityMetadata(), hardwareTagMetadata), payload.tagMetadata) + } + + @Test + fun `activity backup excludes hardware tags`() = test { + whenever { activityRepo.getActivities() }.thenReturn(Result.success(emptyList())) + whenever { activityRepo.getClosedChannels() }.thenReturn(Result.success(emptyList())) + // ActivityRepo already scopes this to the default wallet, so a hardware tag can never appear here. + whenever { activityRepo.getAllActivitiesTags() }.thenReturn(Result.success(listOf(defaultTag()))) + val dataCaptor = argumentCaptor() + + sut.triggerBackup(BackupCategory.ACTIVITY) - assertTrue(result.isSuccess) verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.ACTIVITY.name), dataCaptor.capture()) } val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) - assertEquals(listOf(defaultTag(), hardwareTag), payload.activityTags) + assertTrue(payload.activityTags.none { it.walletId == HARDWARE_WALLET_ID }) } @Test @@ -508,6 +532,7 @@ class BackupRepoTest : BaseUnitTest() { // Read back by the rewrite through getMetadataBackupDataBytes(). whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } .thenReturn(Result.success(restorableMetadata)) + whenever { activityRepo.getHardwareTagsAsPreActivityMetadata() }.thenReturn(Result.success(emptyList())) whenever { pubkyRepo.snapshotSessionBackupState() }.thenReturn(Result.success(null)) whenever { pubkyRepo.snapshotContactProfileOverrides() }.thenReturn(Result.success(null)) } From 2e1d40d310a7d6d596ceef406259287d5344f521 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 18 Aug 2026 08:51:49 -0300 Subject: [PATCH 09/12] fix: use milliseconds for createdAt --- app/src/main/java/to/bitkit/repositories/ActivityRepo.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index bd75551a7..f64ff2250 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -975,6 +975,9 @@ data class ActivityState( val tags: ImmutableList = persistentListOf(), ) +/** Activity timestamps are epoch seconds, while pre-activity metadata stores epoch millis. */ +private const val SECONDS_TO_MILLIS = 1_000uL + /** * Renders an on-chain activity's tags as a [PreActivityMetadata] Core can re-attach later. * @@ -994,6 +997,6 @@ private fun OnchainActivity.toPreActivityMetadata(tags: List): PreActivi feeRate = 0uL, isTransfer = false, channelId = null, - createdAt = timestamp, + createdAt = timestamp * SECONDS_TO_MILLIS, ) } From 43c91721810e101aaade85ede708bc3e0d21da26 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 18 Aug 2026 08:56:50 -0300 Subject: [PATCH 10/12] fix: don't failback for empty list metadata reads, otherwise uploading a partial payload would replace the stored ones --- .../java/to/bitkit/repositories/BackupRepo.kt | 7 ++-- .../to/bitkit/repositories/BackupRepoTest.kt | 34 +++++++++++++++++++ 2 files changed, 39 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 70fc900f3..7396feef5 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -538,10 +538,13 @@ class BackupRepo @Inject constructor( } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { - val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) + // These reads must not fall back to an empty list: this envelope is the only copy of the tags, so + // uploading a partial payload would replace the stored ones. Failing here marks the backup failed + // and leaves the previous upload intact until the retry succeeds. + val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrThrow() // Hardware tags ride here rather than in the activity backup: their activities are rebuilt by the // device watcher, so Core re-attaches them once the watcher recreates the rows. - val hardwareTagMetadata = activityRepo.getHardwareTagsAsPreActivityMetadata().getOrDefault(emptyList()) + val hardwareTagMetadata = activityRepo.getHardwareTagsAsPreActivityMetadata().getOrThrow() val tagMetadata = (preActivityMetadata + hardwareTagMetadata) .distinctBy { it.walletId to it.paymentId } val cacheData = cacheStore.data.first() diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 2d1700217..1f8639aa3 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -459,6 +459,32 @@ class BackupRepoTest : BaseUnitTest() { assertEquals(listOf(preActivityMetadata(), hardwareTagMetadata), payload.tagMetadata) } + @Test + fun `metadata backup fails when pre-activity metadata cannot be read`() = test { + stubMetadataBackupReads() + whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } + .thenReturn(Result.failure(BackupRepoTestError("core unavailable"))) + + val result = sut.triggerBackup(BackupCategory.METADATA) + + assertTrue(result.isFailure) + // Uploading here would replace the stored tags with an empty set. + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `metadata backup fails when hardware tags cannot be read`() = test { + stubMetadataBackupReads() + whenever { activityRepo.getHardwareTagsAsPreActivityMetadata() } + .thenReturn(Result.failure(BackupRepoTestError("core unavailable"))) + + val result = sut.triggerBackup(BackupCategory.METADATA) + + assertTrue(result.isFailure) + // This envelope is the only copy of hardware tags, so a partial upload would lose them. + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + } + @Test fun `activity backup excludes hardware tags`() = test { whenever { activityRepo.getActivities() }.thenReturn(Result.success(emptyList())) @@ -503,6 +529,14 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) } + private fun stubMetadataBackupReads() { + whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } + .thenReturn(Result.success(listOf(preActivityMetadata()))) + whenever { activityRepo.getHardwareTagsAsPreActivityMetadata() }.thenReturn(Result.success(emptyList())) + whenever { pubkyRepo.snapshotSessionBackupState() }.thenReturn(Result.success(null)) + whenever { pubkyRepo.snapshotContactProfileOverrides() }.thenReturn(Result.success(null)) + } + private fun stubActivityRestore( envelope: String = legacyActivityEnvelope(), migratedTagsJson: String = json.encodeToString(listOf(defaultTag())), From 428f8e922b900ce07bf2d6f73e732d1699136ea8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 18 Aug 2026 09:03:08 -0300 Subject: [PATCH 11/12] chore: update comment and log migration failures --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 8 ++++++-- .../test/java/to/bitkit/repositories/BackupRepoTest.kt | 6 +++++- 2 files changed, 11 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 7396feef5..05cbb7ae4 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -750,8 +750,9 @@ class BackupRepo @Inject constructor( * itself. Records that already carry a wallet id are left unchanged, so this * is safe to run on current backups too. * - * A field whose migration fails keeps its original JSON, so a Core failure degrades to the - * pre-migration behaviour instead of losing the whole category. + * A field whose migration fails keeps its original JSON. For an envelope that already carries wallet + * ids that is a no-op, so a Core failure costs nothing. For a legacy envelope the unmigrated field + * then fails to decode and the category is skipped, which [performRestore] logs. */ private suspend fun migrateCoreOwnedBackupFields( raw: String, @@ -815,6 +816,9 @@ class BackupRepo @Inject constructor( cacheStore.updateBackupStatus(category) { it.copy(running = false, synced = createdAtTimestamp, required = createdAtTimestamp) } + }.onFailure { + // Only WALLET is fatal to a full restore, so without this every other category fails silently. + Logger.warn("Failed to restore: '$category'", it, context = TAG) } companion object { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 1f8639aa3..6e84e010b 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -517,7 +517,7 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `failed core migration neither fails the restore nor rewrites the backup`() = test { + fun `failed core migration skips the category without failing the restore`() = test { stubWalletBackup() stubActivityRestore() whenever { activityRepo.migrateBackupActivityTagsJson(any()) } @@ -525,7 +525,11 @@ class BackupRepoTest : BaseUnitTest() { val result = sut.performFullRestoreFromLatestBackup() + // Only WALLET is fatal, so the restore still reports success overall. assertTrue(result.isSuccess) + // The legacy slice stays unmigrated and no longer decodes, so nothing of this category is applied. + verifyBlocking(activityRepo, never()) { restoreFromBackup(any()) } + // Nothing was restored, so the stored backup must not be replaced. verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) } From 98ef072910bd54ebe5d42579e3a2a1065a52b199 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 18 Aug 2026 11:16:55 -0300 Subject: [PATCH 12/12] fix: ordinary activity traffic triggered METADATA upload --- .../to/bitkit/repositories/ActivityRepo.kt | 36 ++++++++++++---- .../java/to/bitkit/repositories/BackupRepo.kt | 5 ++- .../java/to/bitkit/services/CoreService.kt | 37 +++++++++++----- .../bitkit/repositories/ActivityRepoTest.kt | 42 +++++++++++++++++-- .../to/bitkit/repositories/BackupRepoTest.kt | 38 +++++++++++++++++ 5 files changed, 133 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index f64ff2250..6385f5c1d 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -80,7 +80,23 @@ class ActivityRepo @Inject constructor( private val _activitiesChanged = MutableStateFlow(0L) val activitiesChanged: StateFlow = _activitiesChanged - private fun notifyActivitiesChanged() = _activitiesChanged.update { nowMillis(clock) } + private val _activityTagsChanged = MutableStateFlow(0L) + + /** + * Emits only when the stored tag set can have changed, unlike [activitiesChanged] which also fires on + * every payment and sync. Backups that carry tags observe this so they are not rewritten for unrelated + * activity traffic. + */ + val activityTagsChanged: StateFlow = _activityTagsChanged + + /** + * @param tagsChanged whether the stored tag set can have changed. A tag change always implies the + * activity's displayed state changed, so [activityTagsChanged] never fires without [activitiesChanged]. + */ + private fun notifyActivitiesChanged(tagsChanged: Boolean = false) { + if (tagsChanged) _activityTagsChanged.update { nowMillis(clock) } + _activitiesChanged.update { nowMillis(clock) } + } suspend fun resetState() = withContext(bgDispatcher) { _state.update { ActivityState() } @@ -227,14 +243,16 @@ class ActivityRepo @Inject constructor( ): Result> = withContext(bgDispatcher) { runSuspendCatching { val transferChannelIds = transferRepo.getChannelIdsByFundingTxId().getOrDefault(emptyMap()) - val persistedActivities = coreService.activity.replaceHwSnapshot( + val snapshot = coreService.activity.replaceHwSnapshot( walletId = walletId, activities = activities, transactionDetails = transactionDetails, transferChannelIdsByFundingTxId = transferChannelIds, ) - notifyActivitiesChanged() - persistedActivities + // Only a deletion can drop tags, via the cascade. A plain upsert leaves the tag set untouched, + // so it must not trigger a rewrite of the backups that carry tags. + notifyActivitiesChanged(tagsChanged = snapshot.removedActivities) + snapshot.activities }.onFailure { Logger.error("Failed to persist hardware activities for '$walletId'", it, context = TAG) } @@ -243,7 +261,7 @@ class ActivityRepo @Inject constructor( suspend fun deleteForWallet(walletId: String): Result = withContext(bgDispatcher) { runSuspendCatching { val deleted = coreService.activity.deleteByWalletId(walletId) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG) }.onFailure { Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG) @@ -650,7 +668,7 @@ class ActivityRepo @Inject constructor( val deleted = coreService.activity.delete(id, walletId) check(deleted) { "Activity not deleted" } cacheStore.addActivityToDeletedList(id, walletId) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) }.onFailure { Logger.error("deleteActivity error for ID: $id", it, context = TAG) } @@ -748,7 +766,7 @@ class ActivityRepo @Inject constructor( if (newTags.isNotEmpty()) { coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { Logger.info("No new tags to add to activity $activityId", context = TAG) @@ -793,7 +811,7 @@ class ActivityRepo @Inject constructor( } coreService.activity.dropTags(activityId, tags, walletId) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { Logger.error("removeTagsFromActivity error for activity $activityId", it, context = TAG) @@ -921,7 +939,7 @@ class ActivityRepo @Inject constructor( "${payload.closedChannels.size} closed channels", context = TAG, ) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 05cbb7ae4..c0ef32ee1 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -313,8 +313,9 @@ class BackupRepo @Inject constructor( // ACTIVITY - Observe activity changes dataListenerJobs.add(observeBackupChanges(activityRepo.activitiesChanged, BackupCategory.ACTIVITY)) - // Hardware tags are carried by the metadata backup, and tagging emits activitiesChanged. - dataListenerJobs.add(observeBackupChanges(activityRepo.activitiesChanged, BackupCategory.METADATA)) + // Hardware tags are carried by the metadata backup. Observe the narrower tag signal so ordinary + // payment and sync traffic does not re-upload the whole metadata envelope. + dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59193f7e..a9c425fff 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -241,6 +241,18 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 +/** + * Outcome of replacing a hardware wallet's on-chain snapshot. + * + * @param activities the wallet's activities after the replacement. + * @param removedActivities whether any stale activity was deleted. Deletions cascade to that activity's + * tags, so callers that mirror tags elsewhere only need to react when this is true. + */ +data class HwSnapshotResult( + val activities: List, + val removedActivities: Boolean, +) + internal data class HwSnapshotMerge( val toDelete: List, val toUpsert: List, @@ -353,7 +365,7 @@ class ActivityService( activities: List, transactionDetails: List, transferChannelIdsByFundingTxId: Map, - ): List = ServiceQueue.CORE.background { + ): HwSnapshotResult = ServiceQueue.CORE.background { val existingActivities = getActivities( walletId = walletId, filter = ActivityFilter.ONCHAIN, @@ -378,16 +390,19 @@ class ActivityService( if (merge.toUpsert.isNotEmpty()) upsertActivities(merge.toUpsert) if (transactionDetails.isNotEmpty()) upsertTransactionDetails(transactionDetails) - getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = null, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null, + HwSnapshotResult( + activities = getActivities( + walletId = walletId, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ), + removedActivities = merge.toDelete.isNotEmpty(), ) } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 8cc08838f..728a5721f 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -32,6 +32,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock import to.bitkit.models.WalletScope import to.bitkit.services.CoreService +import to.bitkit.services.HwSnapshotResult import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -300,7 +301,7 @@ class ActivityRepoTest : BaseUnitTest() { transactionDetails = emptyList(), transferChannelIdsByFundingTxId = emptyMap(), ) - ).thenReturn(listOf(activity)) + ).thenReturn(HwSnapshotResult(listOf(activity), removedActivities = false)) val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) @@ -308,6 +309,41 @@ class ActivityRepoTest : BaseUnitTest() { verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList(), emptyMap()) } + @Test + fun `persistHwSnapshot signals tag changes only when activities were removed`() = test { + val walletId = "hardware-wallet" + val activity = createOnchainActivity().copy(v1 = baseOnchainActivity.copy(walletId = walletId)) + whenever( + coreService.activity.replaceHwSnapshot( + walletId = walletId, + activities = listOf(activity), + transactionDetails = emptyList(), + transferChannelIdsByFundingTxId = emptyMap(), + ) + ).thenReturn(HwSnapshotResult(listOf(activity), removedActivities = false)) + + val tagsBefore = sut.activityTagsChanged.value + sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) + + // A plain upsert cannot drop tags, so backups carrying tags must not be rewritten for it. + assertEquals(tagsBefore, sut.activityTagsChanged.value) + assertTrue(sut.activitiesChanged.value > 0L) + + whenever( + coreService.activity.replaceHwSnapshot( + walletId = walletId, + activities = listOf(activity), + transactionDetails = emptyList(), + transferChannelIdsByFundingTxId = emptyMap(), + ) + ).thenReturn(HwSnapshotResult(listOf(activity), removedActivities = true)) + + sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) + + // A deletion cascades to that activity's tags, so the tag signal must fire. + assertTrue(sut.activityTagsChanged.value > tagsBefore) + } + @Test fun `persistHwSnapshot forwards known transfer channel ids to the merge`() = test { val walletId = "hardware-wallet" @@ -323,7 +359,7 @@ class ActivityRepoTest : BaseUnitTest() { transactionDetails = emptyList(), transferChannelIdsByFundingTxId = channelIds, ) - ).thenReturn(listOf(activity)) + ).thenReturn(HwSnapshotResult(listOf(activity), removedActivities = false)) val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) @@ -345,7 +381,7 @@ class ActivityRepoTest : BaseUnitTest() { transactionDetails = emptyList(), transferChannelIdsByFundingTxId = emptyMap(), ) - ).thenReturn(listOf(activity)) + ).thenReturn(HwSnapshotResult(listOf(activity), removedActivities = false)) val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 6e84e010b..164cf9d14 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -459,6 +459,43 @@ class BackupRepoTest : BaseUnitTest() { assertEquals(listOf(preActivityMetadata(), hardwareTagMetadata), payload.tagMetadata) } + @Test + fun `activity traffic alone does not trigger a metadata backup`() = test { + val activitiesChanged = MutableStateFlow(0L) + val activityTagsChanged = MutableStateFlow(0L) + stubMetadataBackupReads() + stubBackupObservers() + whenever(activityRepo.activitiesChanged).thenReturn(activitiesChanged) + whenever(activityRepo.activityTagsChanged).thenReturn(activityTagsChanged) + stubBackupStatuses( + MutableStateFlow(emptyMap()), + CompletableDeferred().apply { complete(Unit) }, + ) {} + + try { + sut.startObservingBackups() + runCurrent() + + // A payment or sync bumps activities without touching tags. + activitiesChanged.update { 1L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + // Tagging does bump the tag signal, which the metadata backup must follow. + activityTagsChanged.update { 2L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.METADATA.name), any()) } + } finally { + sut.stopObservingBackups() + } + } + @Test fun `metadata backup fails when pre-activity metadata cannot be read`() = test { stubMetadataBackupReads() @@ -685,6 +722,7 @@ class BackupRepoTest : BaseUnitTest() { whenever { transferDao.observeAll() }.thenReturn(MutableStateFlow(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) + whenever(activityRepo.activityTagsChanged).thenReturn(MutableStateFlow(0L)) whenever(pubkyRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L))