diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 73375d5f0..6385f5c1d 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 @@ -79,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() } @@ -226,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) } @@ -242,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) @@ -649,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) } @@ -747,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) @@ -792,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) @@ -824,7 +843,12 @@ class ActivityRepo @Inject constructor( } /** - * Get all [ActivityTags] for backup + * Get all [ActivityTags] for backup. + * + * 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 { @@ -835,6 +859,67 @@ class ActivityRepo @Inject constructor( } } + /** + * 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. + */ + 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() @@ -854,7 +939,7 @@ class ActivityRepo @Inject constructor( "${payload.closedChannels.size} closed channels", context = TAG, ) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) } } @@ -907,3 +992,29 @@ class ActivityRepo @Inject constructor( 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. + * + * 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 * SECONDS_TO_MILLIS, + ) +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 719821704..c0ef32ee1 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 @@ -81,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( @@ -316,6 +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. 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) @@ -539,14 +539,22 @@ 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().getOrThrow() + 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, @@ -580,28 +588,14 @@ 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( - String(dataBytes), - mapOf("tagMetadata" to ::migrateBackupPreActivityMetadataJson), - ) - val parsed = json.decodeFromString(migrated) - 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() - 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)) @@ -622,16 +616,9 @@ class BackupRepo @Inject constructor( parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> - val migrated = migrateCoreOwnedBackupFields( - String(dataBytes), - mapOf( - "activities" to ::migrateBackupActivitiesJson, - "activityTags" to ::migrateBackupActivityTagsJson, - ), - ) - val parsed = json.decodeFromString(migrated) - activityRepo.restoreFromBackup(parsed) - parsed.createdAt + val restored = restoreActivityBackup(dataBytes) + if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY + restored.createdAt } Logger.info("Full restore success", context = TAG) @@ -643,9 +630,58 @@ class BackupRepo @Inject constructor( _isRestoring.update { false } + if (result.isSuccess) { + rewriteMigratedBackups(categoriesNeedingRewrite) + } + 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) @@ -714,20 +750,49 @@ 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. 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 fun migrateCoreOwnedBackupFields( + private suspend fun migrateCoreOwnedBackupFields( raw: String, - fieldMigrations: Map String>, - ): String { + fieldMigrations: Map Result>, + ): 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) { - patched[field] = json.parseToJsonElement(migrate(element.toString())) - } + if (element !is JsonArray) continue + + migrate(element.toString()) + .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( @@ -752,6 +817,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 { @@ -766,3 +834,27 @@ 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, +) + +/** + * 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 a5c60d01b..5821f2c18 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -10,8 +10,9 @@ import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp -import to.bitkit.services.CoreService +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -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..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(), ) } @@ -587,6 +602,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) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index f560fb88b..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()) @@ -771,15 +807,100 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `getAllActivitiesTags returns only default wallet tags`() = test { val defaultTags = ActivityTags(WalletScope.default, "default-activity", listOf("daily")) - val hardwareTags = ActivityTags("hardware-wallet", "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 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) @@ -1086,4 +1207,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 1ea56a51c..164cf9d14 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,302 @@ 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 `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() + + 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 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() + 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())) + 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) + + verifyBlocking(vssBackupClient) { + putObject(eq(BackupCategory.ACTIVITY.name), dataCaptor.capture()) + } + val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) + assertTrue(payload.activityTags.none { it.walletId == HARDWARE_WALLET_ID }) + } + + @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 skips the category without failing the restore`() = test { + stubWalletBackup() + stubActivityRestore() + whenever { activityRepo.migrateBackupActivityTagsJson(any()) } + .thenReturn(Result.failure(BackupRepoTestError("core migration failed"))) + + 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()) + } + + 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())), + 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 { activityRepo.getHardwareTagsAsPreActivityMetadata() }.thenReturn(Result.success(emptyList())) + 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, @@ -419,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)) @@ -451,6 +755,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, diff --git a/changelog.d/next/1163.fixed.md b/changelog.d/next/1163.fixed.md new file mode 100644 index 000000000..6af14dc8e --- /dev/null +++ b/changelog.d/next/1163.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.