-
Notifications
You must be signed in to change notification settings - Fork 4
feat: persist hardware activities #1044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
687f59f
64cf108
bf57ff0
0182de3
482b94e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,8 +42,10 @@ import to.bitkit.ext.matchesPaymentId | |
| import to.bitkit.ext.nowMillis | ||
| import to.bitkit.ext.nowTimestamp | ||
| import to.bitkit.ext.rawId | ||
| import to.bitkit.ext.runSuspendCatching | ||
| import to.bitkit.models.ActivityBackupV1 | ||
| import to.bitkit.models.PubkyPublicKeyFormat | ||
| import to.bitkit.models.WalletScope | ||
| import to.bitkit.services.CoreService | ||
| import to.bitkit.utils.AppError | ||
| import to.bitkit.utils.Logger | ||
|
|
@@ -178,8 +180,10 @@ class ActivityRepo @Inject constructor( | |
| private suspend fun findClosedChannelForTransaction(txid: String): String? = | ||
| coreService.activity.findClosedChannelForTransaction(txid, null) | ||
|
|
||
| suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? = | ||
| coreService.activity.getOnchainActivityByTxId(txid) | ||
| suspend fun getOnchainActivityByTxId( | ||
| txid: String, | ||
| walletId: String = WalletScope.default, | ||
| ): OnchainActivity? = coreService.activity.getOnchainActivityByTxId(txid, walletId) | ||
|
|
||
| /** | ||
| * Checks if a transaction is inbound (received) by looking up the payment direction. | ||
|
|
@@ -214,23 +218,31 @@ class ActivityRepo @Inject constructor( | |
| notifyActivitiesChanged() | ||
| } | ||
|
|
||
| suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result<Unit> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching | ||
| val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp | ||
| .takeIf { activity.confirmed } | ||
| val updated = existing.copy( | ||
| confirmed = existing.confirmed || activity.confirmed, | ||
| confirmTimestamp = confirmTimestamp, | ||
| doesExist = if (activity.confirmed) true else existing.doesExist, | ||
| fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee, | ||
| updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp), | ||
| suspend fun persistHwSnapshot( | ||
| walletId: String, | ||
| activities: List<Activity>, | ||
| transactionDetails: List<BitkitCoreTransactionDetails>, | ||
| ): Result<List<Activity>> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| val persistedActivities = coreService.activity.replaceHwSnapshot( | ||
| walletId = walletId, | ||
| activities = activities, | ||
| transactionDetails = transactionDetails, | ||
| ) | ||
| if (updated == existing) return@runCatching | ||
| coreService.activity.update(existing.id, Activity.Onchain(updated)) | ||
| notifyActivitiesChanged() | ||
| persistedActivities | ||
| }.onFailure { | ||
| Logger.error("Failed to persist hardware activities for '$walletId'", it, context = TAG) | ||
| } | ||
| } | ||
|
|
||
| suspend fun deleteForWallet(walletId: String): Result<Unit> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| val deleted = coreService.activity.deleteByWalletId(walletId) | ||
| notifyActivitiesChanged() | ||
| Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG) | ||
| }.onFailure { | ||
| Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) | ||
| Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -260,31 +272,50 @@ class ActivityRepo @Inject constructor( | |
| return coreService.activity.shouldShowReceivedSheet(txid, value) | ||
| } | ||
|
|
||
| suspend fun isActivitySeen(activityId: String): Boolean { | ||
| return coreService.activity.isActivitySeen(activityId) | ||
| suspend fun isActivitySeen( | ||
| activityId: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Boolean { | ||
| return coreService.activity.isActivitySeen(activityId, walletId) | ||
| } | ||
|
|
||
| suspend fun markActivityAsSeen(activityId: String) { | ||
| coreService.activity.markActivityAsSeen(activityId) | ||
| suspend fun markActivityAsSeen( | ||
| activityId: String, | ||
| walletId: String = WalletScope.default, | ||
| ) { | ||
| coreService.activity.markActivityAsSeen(activityId, walletId = walletId) | ||
| notifyActivitiesChanged() | ||
| } | ||
|
|
||
| suspend fun markOnchainActivityAsSeen(txid: String) { | ||
| coreService.activity.markOnchainActivityAsSeen(txid) | ||
| suspend fun markOnchainActivityAsSeen( | ||
| txid: String, | ||
| walletId: String = WalletScope.default, | ||
| ) { | ||
| coreService.activity.markOnchainActivityAsSeen(txid, walletId = walletId) | ||
| notifyActivitiesChanged() | ||
| } | ||
|
|
||
| suspend fun getTransactionDetails(txid: String): Result<BitkitCoreTransactionDetails?> = runCatching { | ||
| coreService.activity.getTransactionDetails(txid) | ||
| suspend fun getTransactionDetails( | ||
| txid: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<BitkitCoreTransactionDetails?> = runSuspendCatching { | ||
| coreService.activity.getTransactionDetails(txid, walletId) | ||
| } | ||
|
|
||
| suspend fun getBoostTxDoesExist(boostTxIds: List<String>): Map<String, Boolean> { | ||
| return coreService.activity.getBoostTxDoesExist(boostTxIds) | ||
| suspend fun getBoostTxDoesExist( | ||
| boostTxIds: List<String>, | ||
| walletId: String = WalletScope.default, | ||
| ): Map<String, Boolean> { | ||
| return coreService.activity.getBoostTxDoesExist(boostTxIds, walletId) | ||
| } | ||
|
|
||
| suspend fun isCpfpChildTransaction(txId: String): Boolean = coreService.activity.isCpfpChildTransaction(txId) | ||
| suspend fun isCpfpChildTransaction( | ||
| txId: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Boolean = coreService.activity.isCpfpChildTransaction(txId, walletId) | ||
|
|
||
| suspend fun getTxIdsInBoostTxIds(): Set<String> = coreService.activity.getTxIdsInBoostTxIds() | ||
| suspend fun getTxIdsInBoostTxIds(walletId: String = WalletScope.default): Set<String> = | ||
| coreService.activity.getTxIdsInBoostTxIds(walletId) | ||
|
|
||
| /** | ||
| * Gets a specific activity by payment hash or txID with retry logic | ||
|
|
@@ -330,6 +361,7 @@ class ActivityRepo @Inject constructor( | |
| } | ||
|
|
||
| suspend fun getActivities( | ||
| walletId: String? = WalletScope.default, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separate consequence of the same default: A user whose entire history is a paired hardware wallet now sees blue rows in the list but still gets the empty state driven by |
||
| filter: ActivityFilter? = null, | ||
| txType: PaymentType? = null, | ||
| tags: List<String>? = null, | ||
|
|
@@ -339,8 +371,18 @@ class ActivityRepo @Inject constructor( | |
| limit: UInt? = null, | ||
| sortDirection: SortDirection? = null, | ||
| ): Result<List<Activity>> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) | ||
| runSuspendCatching { | ||
| coreService.activity.get( | ||
| walletId = walletId, | ||
| filter = filter, | ||
| txType = txType, | ||
| tags = tags, | ||
| search = search, | ||
| minDate = minDate, | ||
| maxDate = maxDate, | ||
| limit = limit, | ||
| sortDirection = sortDirection, | ||
| ) | ||
| }.onFailure { | ||
| Logger.error( | ||
| "getActivities error. Parameters:" + | ||
|
|
@@ -358,9 +400,12 @@ class ActivityRepo @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| suspend fun getActivity(id: String): Result<Activity?> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| coreService.activity.getActivity(id) | ||
| suspend fun getActivity( | ||
| id: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<Activity?> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| coreService.activity.getActivity(id, walletId) | ||
| }.onFailure { | ||
| Logger.error("getActivity error for ID: $id", it, context = TAG) | ||
| } | ||
|
|
@@ -472,7 +517,7 @@ class ActivityRepo @Inject constructor( | |
| } | ||
|
|
||
| private suspend fun getActivityByPaymentId(forPaymentId: String): Activity? = | ||
| coreService.activity.getActivity(forPaymentId) | ||
| coreService.activity.getActivity(forPaymentId, WalletScope.default) | ||
| ?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) } | ||
|
|
||
| private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) { | ||
|
|
@@ -530,7 +575,7 @@ class ActivityRepo @Inject constructor( | |
| activity = activity | ||
| ).onSuccess { | ||
| Logger.debug("Activity $id updated with success. new data: $activity", context = TAG) | ||
| val tags = coreService.activity.tags(activityIdToDelete) | ||
| val tags = coreService.activity.tags(activityIdToDelete, WalletScope.default) | ||
| addTagsToActivity(activityId = id, tags = tags) | ||
| }.onFailure { | ||
| Logger.error( | ||
|
|
@@ -593,15 +638,15 @@ class ActivityRepo @Inject constructor( | |
| }.awaitAll() | ||
| } | ||
|
|
||
| suspend fun deleteActivity(id: String): Result<Unit> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| val deleted = coreService.activity.delete(id) | ||
| if (deleted) { | ||
| cacheStore.addActivityToDeletedList(id) | ||
| notifyActivitiesChanged() | ||
| } else { | ||
| return@withContext Result.failure(Exception("Activity not deleted")) | ||
| } | ||
| suspend fun deleteActivity( | ||
| id: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<Unit> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| val deleted = coreService.activity.delete(id, walletId) | ||
| check(deleted) { "Activity not deleted" } | ||
| cacheStore.addActivityToDeletedList(id) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
|
||
| notifyActivitiesChanged() | ||
| }.onFailure { | ||
| Logger.error("deleteActivity error for ID: $id", it, context = TAG) | ||
| } | ||
|
|
@@ -648,7 +693,7 @@ class ActivityRepo @Inject constructor( | |
| runCatching { | ||
| requireNotNull(cjitEntry) | ||
| val id = channel.fundingTxo?.txid.orEmpty() | ||
| if (coreService.activity.getActivity(id) != null) { | ||
| if (coreService.activity.getActivity(id, WalletScope.default) != null) { | ||
| Logger.debug("Skipping CJIT activity insert: already exists for '$id'", context = TAG) | ||
| return@runCatching false | ||
| } | ||
|
|
@@ -687,15 +732,18 @@ class ActivityRepo @Inject constructor( | |
| suspend fun addTagsToActivity( | ||
| activityId: String, | ||
| tags: List<String>, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<Unit> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } | ||
| runSuspendCatching { | ||
| checkNotNull(coreService.activity.getActivity(activityId, walletId)) { | ||
| "Activity with ID $activityId not found" | ||
| } | ||
|
|
||
| val existingTags = coreService.activity.tags(activityId) | ||
| val existingTags = coreService.activity.tags(activityId, walletId) | ||
| val newTags = tags.filter { it.isNotBlank() && it !in existingTags } | ||
|
|
||
| if (newTags.isNotEmpty()) { | ||
| coreService.activity.appendTags(activityId, newTags).getOrThrow() | ||
| coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() | ||
| notifyActivitiesChanged() | ||
| Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) | ||
| } else { | ||
|
|
@@ -729,12 +777,18 @@ class ActivityRepo @Inject constructor( | |
| /** | ||
| * Removes tags from an activity | ||
| */ | ||
| suspend fun removeTagsFromActivity(activityId: String, tags: List<String>): Result<Unit> = | ||
| suspend fun removeTagsFromActivity( | ||
| activityId: String, | ||
| tags: List<String>, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<Unit> = | ||
| withContext(bgDispatcher) { | ||
| runCatching { | ||
| checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } | ||
| runSuspendCatching { | ||
| checkNotNull(coreService.activity.getActivity(activityId, walletId)) { | ||
| "Activity with ID $activityId not found" | ||
| } | ||
|
|
||
| coreService.activity.dropTags(activityId, tags) | ||
| coreService.activity.dropTags(activityId, tags, walletId) | ||
| notifyActivitiesChanged() | ||
| Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) | ||
| }.onFailure { | ||
|
|
@@ -745,9 +799,12 @@ class ActivityRepo @Inject constructor( | |
| /** | ||
| * Gets all tags for an activity | ||
| */ | ||
| suspend fun getActivityTags(activityId: String): Result<List<String>> = withContext(bgDispatcher) { | ||
| runCatching { | ||
| coreService.activity.tags(activityId) | ||
| suspend fun getActivityTags( | ||
| activityId: String, | ||
| walletId: String = WalletScope.default, | ||
| ): Result<List<String>> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| coreService.activity.tags(activityId, walletId) | ||
| }.onFailure { | ||
| Logger.error("getActivityTags error for activity $activityId", it, context = TAG) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 Backup/restore is now asymmetric for hardware tags.
BackupRepo.kt:510callsactivityRepo.getActivities()with no argument, so it resolves toWalletScope.defaultand hardware activities are excluded from the backup payload — reasonable, they're watch-only and rebuildable from the watcher.But
getAllActivitiesTags()(ActivityRepo.kt:826→CoreService.kt:479→com.synonym.bitkitcore.getAllActivitiesTags()) is unscoped, so hardware tag rows do go into the payload.On restore,
upsertTagsthen inserts tags whose parent row doesn't exist in that wallet scope:Core uses
INSERT OR IGNORE INTO activity_tags, and SQLite'sON CONFLICTclause does not apply to FOREIGN KEY constraints. Best case the tags are orphaned and silently lost; worst case the wholeACTIVITYrestore throws.This PR is what makes hardware tags possible in the first place, so it should decide explicitly: either scope the tag backup to the default wallet, or include hardware activities in the payload.