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
9 changes: 9 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ fun Activity.rawId(): String = when (this) {
is Activity.Onchain -> v1.id
}

fun Activity.walletId(): String = when (this) {
is Activity.Lightning -> v1.walletId
is Activity.Onchain -> v1.walletId
}

fun Activity.scopedId(): String = "${walletId()}:${rawId()}"

fun Activity.isFromHardwareWallet(): Boolean = walletId() != WalletScope.default

fun Activity.txType(): PaymentType = when (this) {
is Activity.Lightning -> v1.txType
is Activity.Onchain -> v1.txType
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ data class HwWalletBalance(
data class HwWalletReceivedTx(
val txid: String,
val sats: ULong,
val walletId: String,
)

sealed interface HwFundingAccount {
Expand Down
1 change: 0 additions & 1 deletion app/src/main/java/to/bitkit/models/KnownDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,5 @@ data class KnownDevice(
val xpubs: Map<String, String> = emptyMap(),
/** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */
val customLabel: String? = null,
/** Stable app-owned id for future wallet-scoped hardware activity metadata. */
val walletId: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ data class NewTransactionSheetDetails(
val direction: NewTransactionSheetDirection,
val paymentHashOrTxId: String? = null,
val activityId: String? = null,
val activityWalletId: String? = null,
val sats: Long = 0,
val isLoadingDetails: Boolean = false,
) {
Expand Down
169 changes: 113 additions & 56 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -330,6 +361,7 @@ class ActivityRepo @Inject constructor(
}

suspend fun getActivities(
walletId: String? = WalletScope.default,

Copy link
Copy Markdown
Member

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:510 calls activityRepo.getActivities() with no argument, so it resolves to WalletScope.default and hardware activities are excluded from the backup payload — reasonable, they're watch-only and rebuildable from the watcher.

But getAllActivitiesTags() (ActivityRepo.kt:826CoreService.kt:479com.synonym.bitkitcore.getAllActivitiesTags()) is unscoped, so hardware tag rows do go into the payload.

On restore, upsertTags then inserts tags whose parent row doesn't exist in that wallet scope:

CREATE TABLE activity_tags (
  …, FOREIGN KEY (wallet_id, activity_id) REFERENCES activities(wallet_id, id) ON DELETE CASCADE);

Core uses INSERT OR IGNORE INTO activity_tags, and SQLite's ON CONFLICT clause does not apply to FOREIGN KEY constraints. Best case the tags are orphaned and silently lost; worst case the whole ACTIVITY restore 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate consequence of the same default: HomeViewModel.kt:127 still calls activityRepo.getActivities(limit = 1u) without a scope, so the "has any activity" flag only counts the default wallet.

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 hasActivityFlow. Probably wants walletId = null here, same as ActivityListViewModel.refreshActivityState.

filter: ActivityFilter? = null,
txType: PaymentType? = null,
tags: List<String>? = null,
Expand All @@ -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:" +
Expand All @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deletedActivities cache is still keyed by raw id, not scopedId() — here and at the three read sites (:548, :657, :671), plus CoreService.kt:1069.

deleteActivity has no production caller today so it's latent, but cross-scope id collisions are now an expected state (I confirmed the same txid living in both bitkit and trezor:8df6… on device). Deleting one would suppress the other on re-sync. Worth scoping while everything else is being scoped.

notifyActivitiesChanged()
}.onFailure {
Logger.error("deleteActivity error for ID: $id", it, context = TAG)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
Loading
Loading