diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 6385f5c1d..bae9f6c2b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -928,19 +928,35 @@ class ActivityRepo @Inject constructor( } } + /** + * Applies each slice of the backup envelope on its own so one rejected record cannot discard the others. + * Core fails a bulk write as a whole, so applying all three slices together would let a single unusable tag cost + * the activities and the closed channels too. The overall result still fails when any slice failed, keeping + * [BackupRepo] from treating a partial restore as authoritative and rewriting a good backup with it. + */ suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { - runCatching { - coreService.activity.upsertList(payload.activities) - coreService.activity.upsertTags(payload.activityTags) - coreService.activity.upsertClosedChannelList(payload.closedChannels) - }.onSuccess { - Logger.debug( - "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + - "${payload.closedChannels.size} closed channels", - context = TAG, - ) - notifyActivitiesChanged(tagsChanged = true) + val failures = listOf( + "activities" to runSuspendCatching { coreService.activity.upsertList(payload.activities) }, + "activityTags" to runSuspendCatching { coreService.activity.upsertTags(payload.activityTags) }, + "closedChannels" to runSuspendCatching { + coreService.activity.upsertClosedChannelList(payload.closedChannels) + }, + ).mapNotNull { (slice, result) -> + result.exceptionOrNull()?.also { + Logger.error("Failed to restore '$slice' activity backup slice", it, context = TAG) + } } + + notifyActivitiesChanged(tagsChanged = true) + + failures.firstOrNull()?.let { return@withContext Result.failure(it) } + + Logger.debug( + "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + + "${payload.closedChannels.size} closed channels", + context = TAG, + ) + return@withContext Result.success(Unit) } suspend fun markAllUnseenActivitiesAsSeen(): Result = withContext(bgDispatcher) { diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 728a5721f..15d85bb2b 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity @@ -30,6 +31,7 @@ import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock +import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.services.HwSnapshotResult @@ -69,6 +71,10 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } + private val backupTags by lazy { ActivityTags(WalletScope.default, "activity1", listOf("daily")) } + + private val backupClosedChannel = mock() + private val baseOnchainActivity = OnchainActivity.create( walletId = "wallet0", id = "base_activity_id", @@ -857,6 +863,63 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(emptyList(), result) } + @Test + fun `restoreFromBackup applies every slice and signals tag changes`() = test { + val tagsBefore = sut.activityTagsChanged.value + + val result = sut.restoreFromBackup(backupPayload()) + + assertTrue(result.isSuccess) + verify(coreService.activity).upsertList(listOf(testActivity)) + verify(coreService.activity).upsertTags(listOf(backupTags)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + assertTrue(sut.activityTagsChanged.value > tagsBefore) + } + + @Test + fun `restoreFromBackup applies remaining slices when the tags slice fails`() = test { + whenever(coreService.activity.upsertTags(any())) + .thenThrow(RuntimeException("Failed to insert tag: FOREIGN KEY constraint failed")) + + val result = sut.restoreFromBackup(backupPayload()) + + // One unusable tag must not cost the activities or the closed channels. + verify(coreService.activity).upsertList(listOf(testActivity)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + // Still a failure, so BackupRepo never rewrites a good backup with partial state. + assertTrue(result.isFailure) + } + + @Test + fun `restoreFromBackup applies remaining slices when the activities slice fails`() = test { + whenever(coreService.activity.upsertList(any())).thenThrow(RuntimeException("upsert failed")) + + val result = sut.restoreFromBackup(backupPayload()) + + verify(coreService.activity).upsertTags(listOf(backupTags)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + assertTrue(result.isFailure) + } + + @Test + fun `restoreFromBackup rethrows cancellation`() = test { + val cancellation = CancellationException("cancelled") + whenever(coreService.activity.upsertTags(any())).thenThrow(cancellation) + + val thrown = assertFailsWith { + sut.restoreFromBackup(backupPayload()) + } + + assertEquals(cancellation.message, thrown.message) + } + + private fun backupPayload() = ActivityBackupV1( + createdAt = 1234567890L, + activities = listOf(testActivity), + activityTags = listOf(backupTags), + closedChannels = listOf(backupClosedChannel), + ) + private suspend fun stubHardwareTagLookup(activity: Activity.Onchain) { whenever { coreService.activity.getAllActivitiesTags() } .thenReturn(listOf(ActivityTags(HARDWARE_WALLET_ID, "hw-activity", listOf("cold")))) diff --git a/changelog.d/next/1171.fixed.md b/changelog.d/next/1171.fixed.md new file mode 100644 index 000000000..8950948a3 --- /dev/null +++ b/changelog.d/next/1171.fixed.md @@ -0,0 +1 @@ +Restoring an activity backup no longer discards valid activities or closed channels when a single tag cannot be applied.