Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> = 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<Unit> = withContext(bgDispatcher) {
Expand Down
63 changes: 63 additions & 0 deletions app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ClosedChannelDetails>()

private val baseOnchainActivity = OnchainActivity.create(
walletId = "wallet0",
id = "base_activity_id",
Expand Down Expand Up @@ -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<CancellationException> {
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"))))
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1171.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restoring an activity backup no longer discards valid activities or closed channels when a single tag cannot be applied.
Loading