Skip to content
Open
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
131 changes: 121 additions & 10 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,7 +80,23 @@ class ActivityRepo @Inject constructor(
private val _activitiesChanged = MutableStateFlow(0L)
val activitiesChanged: StateFlow<Long> = _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<Long> = _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() }
Expand Down Expand Up @@ -226,14 +243,16 @@ class ActivityRepo @Inject constructor(
): Result<List<Activity>> = 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)
}
Expand All @@ -242,7 +261,7 @@ class ActivityRepo @Inject constructor(
suspend fun deleteForWallet(walletId: String): Result<Unit> = 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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<List<ActivityTags>> = withContext(bgDispatcher) {
runCatching {
Expand All @@ -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<List<PreActivityMetadata>> =
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<Activity.Onchain>()
.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<String> = 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<String> = withContext(bgDispatcher) {
runSuspendCatching {
coreService.activity.migrateBackupActivityTagsJson(json)
}
}

suspend fun getWalletIds(): Result<Set<String>> = withContext(bgDispatcher) {
runSuspendCatching {
coreService.activity.getWalletIds()
Expand All @@ -854,7 +939,7 @@ class ActivityRepo @Inject constructor(
"${payload.closedChannels.size} closed channels",
context = TAG,
)
notifyActivitiesChanged()
notifyActivitiesChanged(tagsChanged = true)
}
}

Expand Down Expand Up @@ -907,3 +992,29 @@ class ActivityRepo @Inject constructor(
data class ActivityState(
val tags: ImmutableList<String> = 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<String>): 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,
)
}
Loading
Loading