From 687f59f05dcd321bee95e62675fc63c9add0dea9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 22 Jul 2026 20:29:31 +0200 Subject: [PATCH 1/2] feat: persist hardware activities --- app/src/main/java/to/bitkit/ext/Activities.kt | 9 + .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../main/java/to/bitkit/models/KnownDevice.kt | 2 +- .../models/NewTransactionSheetDetails.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 148 +++++++--- .../to/bitkit/repositories/HwWalletRepo.kt | 257 +++++++++++------- .../to/bitkit/repositories/TransferRepo.kt | 20 +- .../java/to/bitkit/services/CoreService.kt | 192 ++++++++++--- app/src/main/java/to/bitkit/ui/ContentView.kt | 26 +- .../screens/contacts/ContactActivityScreen.kt | 6 +- .../screens/wallets/HardwareWalletScreen.kt | 8 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 2 +- .../screens/wallets/SpendingWalletScreen.kt | 2 +- .../wallets/activity/ActivityDetailScreen.kt | 105 ++++--- .../wallets/activity/ActivityExploreScreen.kt | 14 +- .../wallets/activity/AllActivityScreen.kt | 4 +- .../components/ActivityListGrouped.kt | 18 +- .../activity/components/ActivityListSimple.kt | 6 +- .../activity/components/ActivityRow.kt | 8 +- .../viewmodels/ActivityDetailViewModel.kt | 120 ++++---- .../viewmodels/ActivityListViewModel.kt | 66 +++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 8 + .../NotifyPaymentReceivedHandlerTest.kt | 26 +- .../ActivityDetailViewModelTest.kt | 53 +++- .../bitkit/repositories/ActivityRepoTest.kt | 200 +++++++------- .../bitkit/repositories/HwWalletRepoTest.kt | 162 +++++++++-- .../bitkit/repositories/TransferRepoTest.kt | 74 ++++- .../sheets/BoostTransactionViewModelTest.kt | 2 +- .../viewmodels/ActivityListViewModelTest.kt | 36 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 7 +- .../viewmodels/TransferViewModelTest.kt | 11 +- changelog.d/next/1044.changed.md | 1 + journeys/hardware-wallet/README.md | 9 +- .../hardware-wallet/activity-blue-icons.xml | 28 +- .../activity-detail-hw-tags.xml | 42 +++ .../hardware-wallet/transfer-to-spending.xml | 30 +- 38 files changed, 1159 insertions(+), 564 deletions(-) create mode 100644 changelog.d/next/1044.changed.md create mode 100644 journeys/hardware-wallet/activity-detail-hw-tags.xml diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index f6527a5212..80f4754ea8 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -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 diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index 78c1ea5876..3b8e93490e 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -37,6 +37,7 @@ data class HwWalletBalance( data class HwWalletReceivedTx( val txid: String, val sats: ULong, + val walletId: String, ) sealed interface HwFundingAccount { diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt index 478afb9268..4429c2a20c 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -17,6 +17,6 @@ data class KnownDevice( val xpubs: Map = 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. */ + /** Stable Bitkit Core wallet scope for hardware activity metadata. */ val walletId: String = "", ) diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 11b4610d71..004c985fd1 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -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, ) { diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7c94a9222d..69905df9bd 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -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 = 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 persistHardware( + walletId: String, + activities: List, + transactionDetails: List, + ): Result> = withContext(bgDispatcher) { + runSuspendCatching { + val persistedActivities = coreService.activity.replaceHardwareSnapshot( + 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 = 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 = runCatching { - coreService.activity.getTransactionDetails(txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String = WalletScope.default, + ): Result = runSuspendCatching { + coreService.activity.getTransactionDetails(txid, walletId) } - suspend fun getBoostTxDoesExist(boostTxIds: List): Map { - return coreService.activity.getBoostTxDoesExist(boostTxIds) + suspend fun getBoostTxDoesExist( + boostTxIds: List, + walletId: String = WalletScope.default, + ): Map { + 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 = coreService.activity.getTxIdsInBoostTxIds() + suspend fun getTxIdsInBoostTxIds(walletId: String = WalletScope.default): Set = + 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, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -340,7 +372,17 @@ class ActivityRepo @Inject constructor( sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + 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 = withContext(bgDispatcher) { + suspend fun getActivity( + id: String, + walletId: String = WalletScope.default, + ): Result = withContext(bgDispatcher) { runCatching { - coreService.activity.getActivity(id) + 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,9 +638,12 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - suspend fun deleteActivity(id: String): Result = withContext(bgDispatcher) { + suspend fun deleteActivity( + id: String, + walletId: String = WalletScope.default, + ): Result = withContext(bgDispatcher) { runCatching { - val deleted = coreService.activity.delete(id) + val deleted = coreService.activity.delete(id, walletId) if (deleted) { cacheStore.addActivityToDeletedList(id) notifyActivitiesChanged() @@ -648,7 +696,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 +735,18 @@ class ActivityRepo @Inject constructor( suspend fun addTagsToActivity( activityId: String, tags: List, + walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + 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 +780,18 @@ class ActivityRepo @Inject constructor( /** * Removes tags from an activity */ - suspend fun removeTagsFromActivity(activityId: String, tags: List): Result = + suspend fun removeTagsFromActivity( + activityId: String, + tags: List, + walletId: String = WalletScope.default, + ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + 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 +802,12 @@ class ActivityRepo @Inject constructor( /** * Gets all tags for an activity */ - suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { + suspend fun getActivityTags( + activityId: String, + walletId: String = WalletScope.default, + ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.tags(activityId) + coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 564fb800df..bc3b36f1d7 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.async.appScope import to.bitkit.data.HwWalletStore @@ -34,9 +36,10 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isTrezorUserCancellation -import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp +import to.bitkit.ext.walletId import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -90,6 +93,7 @@ class HwWalletRepo @Inject constructor( } private val scope = appScope(ioDispatcher, TAG) + private val watcherMutex = Mutex() private val activeWatchers = mutableSetOf() private val activeWatcherElectrumUrls = mutableMapOf() @@ -97,6 +101,7 @@ class HwWalletRepo @Inject constructor( private val retryingWatcherStarts = mutableSetOf() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) + private val persistedActivityIds = mutableMapOf>() private val emittedReceivedTxIds = mutableSetOf() private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) @@ -112,16 +117,19 @@ class HwWalletRepo @Inject constructor( fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId) suspend fun resetState() = withContext(ioDispatcher) { - activeWatchers.toList().forEach { watcherId -> - trezorRepo.stopWatcher(watcherId) - .onFailure { Logger.warn("Failed to stop watcher '$watcherId' while resetting", it, context = TAG) } + watcherMutex.withLock { + activeWatchers.toList().forEach { watcherId -> + trezorRepo.stopWatcher(watcherId) + .onFailure { Logger.warn("Failed to stop watcher '$watcherId' while resetting", it, context = TAG) } + } + activeWatchers.clear() + activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() + retryingWatcherStarts.clear() + persistedActivityIds.clear() + emittedReceivedTxIds.clear() + _watcherData.update { emptyMap() } } - activeWatchers.clear() - activeWatcherElectrumUrls.clear() - activeWatcherWalletIds.clear() - retryingWatcherStarts.clear() - emittedReceivedTxIds.clear() - _watcherData.update { emptyMap() } trezorRepo.resetState() } @@ -189,6 +197,16 @@ class HwWalletRepo @Inject constructor( } } + suspend fun getWalletId(deviceId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val devices = hwWalletStore.loadKnownDevices() + val target = requireNotNull(devices.find { it.id == deviceId }) { + "Unknown hardware wallet '$deviceId'" + } + requireNotNull(target.resolvedWalletId()) { "Hardware wallet '$deviceId' has no wallet id" } + } + } + /** Composes the exact on-chain funding payment before prompting for the Trezor signature. */ suspend fun composeFundingTransaction( deviceId: String, @@ -286,22 +304,28 @@ class HwWalletRepo @Inject constructor( * single id would leave the tile reappearing through the other transport. */ suspend fun removeDevice(deviceId: String): Result = withContext(ioDispatcher) { - runCatching { - val knownDevices = hwWalletStore.loadKnownDevices() - val target = knownDevices.find { it.id == deviceId } - val ids = when (target) { - null -> setOf(deviceId) - else -> knownDevices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() - } - activeWatchers.toList() - .filter { it.toDeviceId() in ids } - .forEach { - if (!stopActiveWatcher(it)) throw AppError("Failed to stop hardware wallet watcher '$it'") + runSuspendCatching { + watcherMutex.withLock { + val knownDevices = hwWalletStore.loadKnownDevices() + val target = knownDevices.find { it.id == deviceId } + val walletId = target?.resolvedWalletId() + val ids = when (target) { + null -> setOf(deviceId) + else -> knownDevices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() } - val failures = ids.mapNotNull { trezorRepo.forgetDevice(it).exceptionOrNull() } - val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() - failures.firstOrNull()?.let { throw it } - check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } + activeWatchers.toList() + .filter { it.toDeviceId() in ids } + .forEach { + if (!stopActiveWatcherLocked(it)) { + throw AppError("Failed to stop hardware wallet watcher '$it'") + } + } + walletId?.let { activityRepo.deleteForWallet(it).getOrThrow() } + val failures = ids.mapNotNull { trezorRepo.forgetDevice(it).exceptionOrNull() } + val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() + failures.firstOrNull()?.let { throw it } + check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } + } }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -375,20 +399,34 @@ class HwWalletRepo @Inject constructor( scope.launch { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect - val previous = _watcherData.value[watcherId] - val activities = event.activities.toImmutableList() - val watcher = HwWatcherData( - deviceId = watcherId.toDeviceId(), - addressType = watcherId.toAddressTypeKey(), - balanceSats = event.balance.total, - activities = activities, - ) - _watcherData.update { it + (watcherId to watcher) } - val updatedWatcherData = _watcherData.value - activities.filterIsInstance().forEach { - activityRepo.syncHardwareOnchainActivity(it.v1) + watcherMutex.withLock { + val walletId = activeWatcherWalletIds[watcherId] ?: return@withLock + val activities = event.activities.filter { it.walletId() == walletId } + val transactionDetails = event.transactionDetails.filter { it.walletId == walletId } + val watcher = HwWatcherData( + deviceId = watcherId.toDeviceId(), + walletId = walletId, + addressType = watcherId.toAddressTypeKey(), + balanceSats = event.balance.total, + activities = activities.toImmutableList(), + ) + _watcherData.update { it + (watcherId to watcher) } + val previousIds = persistedActivityIds.getOrPut(watcherId) { + activities.map { it.scopedId() }.toSet() + } + + val persistedActivities = activityRepo.persistHardware( + walletId = walletId, + activities = activities, + transactionDetails = transactionDetails, + ).getOrElse { return@withLock } + val persistedWatcher = watcher.copy(activities = persistedActivities.toImmutableList()) + val updatedWatcherData = _watcherData.value + (watcherId to persistedWatcher) + _watcherData.update { updatedWatcherData } + + persistedActivityIds[watcherId] = persistedActivities.map { it.scopedId() }.toSet() + emitReceivedTxs(previousIds, persistedActivities, updatedWatcherData) } - emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -398,22 +436,25 @@ class HwWalletRepo @Inject constructor( * treat it as the baseline so only transactions arriving while watching are emitted. */ private suspend fun emitReceivedTxs( - previous: HwWatcherData?, + previousActivityIds: Set, activities: List, watcherData: Map, ) { - if (previous == null) return - val knownTxIds = previous.activities.mapNotNull { activity -> - (activity as? Activity.Onchain)?.v1?.txId - }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() activities.filterIsInstance() .filter { it.v1.txType == PaymentType.RECEIVED } .forEach { onchain -> - val txid = onchain.v1.txId - if (txid in knownTxIds || !emittedReceivedTxIds.add(txid)) return@forEach - val sats = mergedActivities.findOnchain(txid)?.v1?.value ?: onchain.v1.value - _receivedTxs.emit(HwWalletReceivedTx(txid = txid, sats = sats)) + val scopedId = onchain.scopedId() + if (scopedId in previousActivityIds || !emittedReceivedTxIds.add(scopedId)) return@forEach + val sats = mergedActivities.findOnchain(onchain.v1.txId, onchain.v1.walletId)?.v1?.value + ?: onchain.v1.value + _receivedTxs.emit( + HwWalletReceivedTx( + txid = onchain.v1.txId, + sats = sats, + walletId = onchain.v1.walletId, + ) + ) } } @@ -434,68 +475,73 @@ class HwWalletRepo @Inject constructor( ) { desired, _ -> desired }.collect { (knownDevices, watcherSettings) -> - // Trezor v1 watches native segwit only (not global Advanced address-type monitoring). - // Xpubs for all types are still captured on connect for a future multi-type release. - // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. - val filtered = knownDevices.flatMap { device -> - val walletId = device.resolvedWalletId() ?: return@flatMap emptyList() - device.xpubs - .filterKeys { it in SUPPORTED_WATCHER_ADDRESS_TYPES } - .map { (addressType, xpub) -> - WatcherSpec( - deviceId = device.id, - addressType = addressType, - xpub = xpub, - electrumUrl = watcherSettings.electrumUrl, - walletId = walletId, - ) - } - }.distinctBy { it.addressType to it.xpub } - val filteredIds = filtered.map { it.watcherId }.toSet() - - filtered.forEach { spec -> - val isActive = spec.watcherId in activeWatchers - if ( - isActive && - activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && - activeWatcherWalletIds[spec.watcherId] == spec.walletId - ) { - return@forEach - } - if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach - - trezorRepo.startWatcher( - watcherId = spec.watcherId, - extendedKey = spec.xpub, - network = Env.network.toCoreNetwork(), - accountType = spec.addressType.toAddressType()?.toAccountType(), - electrumUrl = spec.electrumUrl, - walletId = spec.walletId, - ).onSuccess { - activeWatchers += spec.watcherId - activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl - activeWatcherWalletIds[spec.watcherId] = spec.walletId - retryingWatcherStarts -= spec.watcherId - }.onFailure { - Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) - scheduleWatcherStartRetry(spec.watcherId) - } - } + reconcileWatchers(knownDevices, watcherSettings) + } + } + } - // A failed stop stays active so the next sync retries it; dropping it here - // would leave the orphaned watcher feeding _watcherData as a ghost balance. - (activeWatchers - filteredIds).forEach { staleId -> - stopActiveWatcher(staleId) - } + private suspend fun reconcileWatchers( + knownDevices: List, + watcherSettings: WatcherSettings, + ) = watcherMutex.withLock { + val specs = knownDevices.toWatcherSpecs(watcherSettings.electrumUrl) + val desiredIds = specs.map { it.watcherId }.toSet() + + specs.forEach { spec -> + val isActive = spec.watcherId in activeWatchers + if ( + isActive && + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && + activeWatcherWalletIds[spec.watcherId] == spec.walletId + ) { + return@forEach + } + if (isActive && !stopActiveWatcherLocked(spec.watcherId)) return@forEach + + trezorRepo.startWatcher( + watcherId = spec.watcherId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + electrumUrl = spec.electrumUrl, + walletId = spec.walletId, + ).onSuccess { + activeWatchers += spec.watcherId + activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + activeWatcherWalletIds[spec.watcherId] = spec.walletId + retryingWatcherStarts -= spec.watcherId + }.onFailure { + Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) + scheduleWatcherStartRetry(spec.watcherId) } } + + // A failed stop stays active so the next sync retries it; dropping it here + // would leave the orphaned watcher feeding _watcherData as a ghost balance. + (activeWatchers - desiredIds).forEach { stopActiveWatcherLocked(it) } } - private suspend fun stopActiveWatcher(watcherId: String): Boolean = + private fun List.toWatcherSpecs(electrumUrl: String): List = flatMap { device -> + val walletId = device.resolvedWalletId() ?: return@flatMap emptyList() + device.xpubs + .filterKeys { it in SUPPORTED_WATCHER_ADDRESS_TYPES } + .map { (addressType, xpub) -> + WatcherSpec( + deviceId = device.id, + addressType = addressType, + xpub = xpub, + electrumUrl = electrumUrl, + walletId = walletId, + ) + } + }.distinctBy { it.addressType to it.xpub } + + private suspend fun stopActiveWatcherLocked(watcherId: String): Boolean = trezorRepo.stopWatcher(watcherId).onSuccess { activeWatchers -= watcherId activeWatcherElectrumUrls -= watcherId activeWatcherWalletIds -= watcherId + persistedActivityIds -= watcherId _watcherData.update { it - watcherId } }.isSuccess @@ -504,7 +550,9 @@ class HwWalletRepo @Inject constructor( scope.launch { delay(WATCHER_START_RETRY_DELAY) - retryingWatcherStarts -= watcherId + watcherMutex.withLock { + retryingWatcherStarts -= watcherId + } watcherSyncRequests.emit(Unit) } } @@ -514,7 +562,7 @@ class HwWalletRepo @Inject constructor( private fun List.toMergedActivities(): List = flatMap { it.activities } - .groupBy { it.rawId() } + .groupBy { it.scopedId() } .values .map { it.mergedActivity() } .sortedByDescending { it.timestamp() } @@ -565,8 +613,8 @@ class HwWalletRepo @Inject constructor( ) } - private fun List.findOnchain(txid: String) = filterIsInstance() - .firstOrNull { it.v1.txId == txid } + private fun List.findOnchain(txid: String, walletId: String) = filterIsInstance() + .firstOrNull { it.v1.txId == txid && it.v1.walletId == walletId } private data class WatcherSpec( val deviceId: String, @@ -613,6 +661,7 @@ private val KnownDevice.displayName: String private data class HwWatcherData( val deviceId: String, + val walletId: String, val addressType: String, val balanceSats: ULong, val activities: ImmutableList, diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 449a8f56c2..e40f47292b 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -20,6 +21,7 @@ import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType +import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger @@ -107,6 +109,7 @@ class TransferRepo @Inject constructor( txId: String, fee: ULong, feeRate: ULong, + walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { runSuspendCatching { val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { @@ -120,6 +123,7 @@ class TransferRepo @Inject constructor( feeRate = feeRate, isTransfer = true, channelId = order.channel?.shortChannelId, + walletId = walletId, ) }.onFailure { Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) @@ -260,7 +264,21 @@ class TransferRepo @Inject constructor( } private suspend fun markActivityAsTransfer(txid: String, channelId: String) { - val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + val activity = coreService.activity.get( + walletId = null, + filter = ActivityFilter.ONCHAIN, + search = txid, + limit = 10u, + sortDirection = SortDirection.DESC, + ).filterIsInstance() + .filter { it.v1.txId == txid } + .let { matches -> + matches.firstOrNull { it.v1.isTransfer } + ?: matches.firstOrNull { + it.v1.walletId != WalletScope.default && it.v1.txType == PaymentType.SENT + } + ?: matches.firstOrNull() + }?.v1 ?: return if (activity.isTransfer && activity.channelId == channelId) return val updated = activity.copy(isTransfer = true, channelId = channelId) coreService.activity.update(activity.id, Activity.Onchain(updated)) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index c5540516b9..9fd523c26f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -31,7 +31,9 @@ import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags import com.synonym.bitkitcore.createCjitEntry import com.synonym.bitkitcore.createOrder +import com.synonym.bitkitcore.deleteActivitiesByWalletId import com.synonym.bitkitcore.deleteActivityById +import com.synonym.bitkitcore.deleteTransactionDetails import com.synonym.bitkitcore.deriveOnchainDescriptor import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities @@ -85,9 +87,13 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.walletId import to.bitkit.models.ALL_ADDRESS_TYPES -import to.bitkit.models.WalletScope import to.bitkit.models.DEFAULT_ADDRESS_TYPE +import to.bitkit.models.WalletScope import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType @@ -243,13 +249,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { - private val walletId = WalletScope.default + private val defaultWalletId = WalletScope.default suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( - walletId = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -260,15 +266,18 @@ class ActivityService( sortDirection = null, ) for (activity in activities) { - val id = when (activity) { - is Activity.Lightning -> activity.v1.id - is Activity.Onchain -> activity.v1.id + when (activity) { + is Activity.Lightning -> deleteActivityById(activity.v1.walletId, activity.v1.id) + is Activity.Onchain -> deleteActivityById(activity.v1.walletId, activity.v1.id) } - deleteActivityById(walletId = walletId, activityId = id) } } } + suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + deleteActivitiesByWalletId(walletId) + } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { insertActivity(activity) } @@ -281,9 +290,62 @@ class ActivityService( upsertActivities(activities) } + suspend fun replaceHardwareSnapshot( + walletId: String, + activities: List, + transactionDetails: List, + ): List = ServiceQueue.CORE.background { + val existingActivities = getActivities( + walletId = walletId, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ).filterIsInstance() + val incomingIds = activities.map { it.rawId() }.toSet() + existingActivities + .filter { !it.v1.isTransfer && it.v1.id !in incomingIds } + .forEach { + deleteActivityById(walletId = walletId, activityId = it.v1.id) + deleteTransactionDetails(walletId = walletId, txId = it.v1.txId) + } + + val existingByTxId = existingActivities.associateBy { it.v1.txId } + val mergedActivities = activities.map { activity -> + val onchain = activity as? Activity.Onchain ?: return@map activity + val existing = existingByTxId[onchain.v1.txId]?.v1 ?: return@map activity + Activity.Onchain( + onchain.v1.copy( + isTransfer = onchain.v1.isTransfer || existing.isTransfer, + channelId = onchain.v1.channelId ?: existing.channelId, + transferTxId = onchain.v1.transferTxId ?: existing.transferTxId, + ) + ) + } + if (mergedActivities.isNotEmpty()) upsertActivities(mergedActivities) + if (transactionDetails.isNotEmpty()) upsertTransactionDetails(transactionDetails) + + getActivities( + walletId = walletId, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + } + private fun mapToCoreTransactionDetails( txid: String, details: TransactionDetails, + walletId: String = defaultWalletId, ): BitkitCoreTransactionDetails { val inputs = details.inputs.map { input -> BitkitCoreTxInput( @@ -312,15 +374,21 @@ class ActivityService( ) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { + suspend fun getTransactionDetails( + txid: String, + walletId: String = defaultWalletId, + ): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) } - suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { + suspend fun getActivity(id: String, walletId: String = defaultWalletId): Activity? = ServiceQueue.CORE.background { getActivityById(walletId = walletId, activityId = id) } - suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { + suspend fun getOnchainActivityByTxId( + txId: String, + walletId: String = defaultWalletId, + ): OnchainActivity? = ServiceQueue.CORE.background { getActivityByTxId(walletId = walletId, txId = txId) } @@ -334,6 +402,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = defaultWalletId, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -360,23 +429,32 @@ class ActivityService( updateActivity(activityId = id, activity = activity) } - suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { + suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { deleteActivityById(walletId = walletId, activityId = id) } - suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { + suspend fun appendTags( + toActivityId: String, + tags: List, + walletId: String = defaultWalletId, + ): Result = runSuspendCatching { ServiceQueue.CORE.background { addTags(walletId = walletId, activityId = toActivityId, tags = tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { + suspend fun dropTags( + fromActivityId: String, + tags: List, + walletId: String = defaultWalletId, + ) = ServiceQueue.CORE.background { removeTags(walletId = walletId, activityId = fromActivityId, tags = tags) } - suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(walletId = walletId, activityId = forActivityId) - } + suspend fun tags(forActivityId: String, walletId: String = defaultWalletId): List = + ServiceQueue.CORE.background { + getTags(walletId = walletId, activityId = forActivityId) + } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { getAllUniqueTags() @@ -404,7 +482,7 @@ class ActivityService( suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.addPreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, tags = tags, ) @@ -412,14 +490,14 @@ class ActivityService( suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.removePreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, tags = tags, ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = defaultWalletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( @@ -427,14 +505,14 @@ class ActivityService( searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { com.synonym.bitkitcore.getPreActivityMetadata( - walletId = walletId, + walletId = defaultWalletId, searchKey = searchKey, searchByAddress = searchByAddress, ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = defaultWalletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -537,7 +615,7 @@ class ActivityService( return } - val existingActivity = getActivityById(walletId = walletId, activityId = payment.id) + val existingActivity = getActivityById(walletId = defaultWalletId, activityId = payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -577,7 +655,7 @@ class ActivityService( ) } - if (getActivityById(walletId = walletId, activityId = payment.id) != null) { + if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) { updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) @@ -917,7 +995,7 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(walletId = walletId, activityId = payment.id) + var existingActivity = getActivityById(walletId = defaultWalletId, activityId = payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) @@ -1101,15 +1179,25 @@ class ActivityService( feeRate: ULong, isTransfer: Boolean, channelId: String?, + walletId: String = defaultWalletId, ) { ServiceQueue.CORE.background { runCatching { - if (getOnchainActivityByTxId(txId = txid) != null) { + val existing = getOnchainActivityByTxId(txId = txid, walletId = walletId) + if (existing != null) { + if (isTransfer) { + val updated = existing.copy( + isTransfer = true, + channelId = existing.channelId ?: channelId, + ) + if (updated != existing) upsertActivity(Activity.Onchain(updated)) + } Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background } - val now = System.currentTimeMillis().toULong() / 1000u + val now = nowTimestamp().epochSecond.toULong() val onchain = OnchainActivity.create( + walletId = walletId, id = txid, txType = PaymentType.SENT, txId = txid, @@ -1416,7 +1504,10 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { + suspend fun isActivitySeen( + activityId: String, + walletId: String = defaultWalletId, + ): Boolean = ServiceQueue.CORE.background { val activity = getActivityById(walletId = walletId, activityId = activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null @@ -1424,13 +1515,17 @@ class ActivityService( } } - suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { + suspend fun markActivityAsSeen( + activityId: String, + walletId: String = defaultWalletId, + seenAt: ULong? = null, + ) = ServiceQueue.CORE.background { val activity = getActivityById(walletId = walletId, activityId = activityId) ?: run { Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) return@background } - val timestamp = seenAt ?: (System.currentTimeMillis().toULong() / 1000u) + val timestamp = seenAt ?: nowTimestamp().epochSecond.toULong() val updatedActivity = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(seenAt = timestamp)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp)) @@ -1440,20 +1535,24 @@ class ActivityService( Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) } - suspend fun markOnchainActivityAsSeen(txid: String, seenAt: ULong? = null) { + suspend fun markOnchainActivityAsSeen( + txid: String, + walletId: String = defaultWalletId, + seenAt: ULong? = null, + ) { val activity = ServiceQueue.CORE.background { - getOnchainActivityByTxId(txid) + getOnchainActivityByTxId(txid, walletId) } ?: run { Logger.warn("Cannot mark onchain activity as seen - activity not found for txid: $txid", context = TAG) return } - markActivityAsSeen(activity.id, seenAt) + markActivityAsSeen(activity.id, walletId = activity.walletId, seenAt = seenAt) } suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { - val timestamp = (System.currentTimeMillis() / 1000).toULong() + val timestamp = nowTimestamp().epochSecond.toULong() val activities = getActivities( - walletId = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -1471,20 +1570,19 @@ class ActivityService( } if (!isSeen) { - val activityId = when (activity) { - is Activity.Onchain -> activity.v1.id - is Activity.Lightning -> activity.v1.id - } - markActivityAsSeen(activityId, timestamp) + markActivityAsSeen(activity.rawId(), walletId = activity.walletId(), seenAt = timestamp) } } } - suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + suspend fun getBoostTxDoesExist( + boostTxIds: List, + walletId: String = defaultWalletId, + ): Map { return ServiceQueue.CORE.background { val doesExistMap = mutableMapOf() for (boostTxId in boostTxIds) { - val boostActivity = getOnchainActivityByTxId(boostTxId) + val boostActivity = getOnchainActivityByTxId(boostTxId, walletId) if (boostActivity != null) { doesExistMap[boostTxId] = boostActivity.doesExist && !boostActivity.isBoosted } @@ -1493,21 +1591,25 @@ class ActivityService( } } - suspend fun isCpfpChildTransaction(txId: String): Boolean { + suspend fun isCpfpChildTransaction( + txId: String, + walletId: String = defaultWalletId, + ): Boolean { return ServiceQueue.CORE.background { - val txIdsInBoostTxIds = getTxIdsInBoostTxIds() + val txIdsInBoostTxIds = getTxIdsInBoostTxIds(walletId) if (!txIdsInBoostTxIds.contains(txId)) { return@background false } - val activity = getOnchainActivityByTxId(txId) ?: return@background false + val activity = getOnchainActivityByTxId(txId, walletId) ?: return@background false return@background activity.doesExist } } - suspend fun getTxIdsInBoostTxIds(): Set { + suspend fun getTxIdsInBoostTxIds(walletId: String = defaultWalletId): Set { return ServiceQueue.CORE.background { val allOnchainActivities = get( + walletId = walletId, filter = ActivityFilter.ONCHAIN, txType = null, tags = null, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 6d6615fe64..a350ee7179 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -42,6 +42,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import com.synonym.bitkitcore.Activity import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -52,6 +53,8 @@ import kotlinx.serialization.Serializable import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env +import to.bitkit.ext.rawId +import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState @@ -1039,7 +1042,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1056,7 +1059,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, ) } } @@ -1595,10 +1598,11 @@ private fun NavGraphBuilder.activityItem( settingsViewModel: SettingsViewModel, ) { composableWithDefaultTransitions { + val route = it.toRoute() ActivityDetailScreen( listViewModel = activityListViewModel, - route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + route = route, + onExploreClick = { id -> navController.navigateToActivityExplore(id, route.walletId) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1861,9 +1865,15 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(activity: Activity) = navigateTo( + Routes.ActivityDetail( + id = activity.rawId(), + walletId = activity.walletId(), + ) +) -fun NavController.navigateToActivityExplore(id: String) = navigateTo(Routes.ActivityExplore(id)) +fun NavController.navigateToActivityExplore(id: String, walletId: String?) = + navigateTo(Routes.ActivityExplore(id, walletId)) fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogDetail(fileName)) @@ -2084,13 +2094,13 @@ sealed interface Routes { data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes @Serializable - data class ActivityDetail(val id: String) : Routes + data class ActivityDetail(val id: String, val walletId: String? = null) : Routes @Serializable data class ActivityAssignContact(val id: String) : Routes @Serializable - data class ActivityExplore(val id: String) : Routes + data class ActivityExplore(val id: String, val walletId: String? = null) : Routes @Serializable data object BuyIntro : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt index 80d8c507f5..a130d4eebb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -37,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun ContactActivityScreen( viewModel: ContactActivityViewModel, onBackClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 9b1d45116f..159bafd790 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -39,7 +39,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BalanceHeaderView @@ -62,7 +62,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( deviceId: String, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), @@ -95,7 +95,7 @@ fun HardwareWalletScreen( private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,7 @@ private fun HardwareWalletContent( // Every activity here belongs to the watch-only device, so render them all with the blue // hardware icon, matching the home list. - val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.rawId() }.toImmutableSet() } + val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index cc3ea5f20d..7c8e4d1fc5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (Activity) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, @@ -588,7 +588,7 @@ private fun WalletPage( onRefresh: () -> Unit, onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, - onNavigateToActivityItem: (String) -> Unit, + onNavigateToActivityItem: (Activity) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 45efea812a..dd9d5cd21e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -62,7 +62,7 @@ fun SavingsWalletScreen( onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2cedaeafb5..9432a30df7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -63,7 +63,7 @@ fun SpendingWalletScreen( channels: ImmutableList, lightningActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 2a295347c0..b05478f842 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -115,8 +115,8 @@ fun ActivityDetailScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -598,60 +598,55 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware - if (showContactActions || showTagAction) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (showContactActions) { - PrimaryButton( - text = stringResource( - if (assignedContact != null) { - R.string.wallet__activity_detach - } else { - R.string.wallet__activity_assign - } - ), - size = ButtonSize.Small, - onClick = if (assignedContact != null) onDetachClick else onAssignClick, - enabled = !isSelfSend, - icon = { - Icon( - painter = painterResource( - if (assignedContact != null) { - R.drawable.ic_user_minus - } else { - R.drawable.ic_user_plus - } - ), - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.weight(1f) - ) - } - if (showTagAction) { - PrimaryButton( - text = stringResource(R.string.wallet__activity_tag), - size = ButtonSize.Small, - onClick = onAddTagClick, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier - .weight(1f) - .testTag("ActivityTag") - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (showContactActions) { + PrimaryButton( + text = stringResource( + if (assignedContact != null) { + R.string.wallet__activity_detach + } else { + R.string.wallet__activity_assign + } + ), + size = ButtonSize.Small, + onClick = if (assignedContact != null) onDetachClick else onAssignClick, + enabled = !isSelfSend, + icon = { + Icon( + painter = painterResource( + if (assignedContact != null) { + R.drawable.ic_user_minus + } else { + R.drawable.ic_user_plus + } + ), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f) + ) } + PrimaryButton( + text = stringResource(R.string.wallet__activity_tag), + size = ButtonSize.Small, + onClick = onAddTagClick, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("ActivityTag") + ) } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 45f2469ae4..ed039564cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -75,8 +75,8 @@ fun ActivityExploreScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -136,6 +136,8 @@ fun ActivityExploreScreen( val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() + val isTransactionDetailsLoading by + detailViewModel.isTransactionDetailsLoading.collectAsStateWithLifecycle() var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { @@ -166,6 +168,7 @@ fun ActivityExploreScreen( item = item, isHardware = uiState.isHardwareActivity, txDetails = txDetails, + isTransactionDetailsLoading = isTransactionDetailsLoading, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -190,6 +193,7 @@ private fun ActivityExploreContent( item: Activity, isHardware: Boolean = false, txDetails: TransactionDetails? = null, + isTransactionDetailsLoading: Boolean = false, boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, @@ -221,9 +225,9 @@ private fun ActivityExploreContent( is Activity.Onchain -> { OnchainDetails( onchain = item, - isHardware = isHardware, onCopy = onCopy, txDetails = txDetails, + isTransactionDetailsLoading = isTransactionDetailsLoading, boostTxDoesExist = boostTxDoesExist, ) Spacer(modifier = Modifier.weight(1f)) @@ -284,9 +288,9 @@ private fun LightningDetails( @Composable private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, - isHardware: Boolean, onCopy: (String) -> Unit, txDetails: TransactionDetails?, + isTransactionDetailsLoading: Boolean, boostTxDoesExist: Map = emptyMap(), ) { val txId = onchain.v1.txId @@ -327,7 +331,7 @@ private fun ColumnScope.OnchainDetails( } }, ) - } else if (!isHardware && !onchain.v1.isTransfer) { + } else if (isTransactionDetailsLoading && !onchain.v1.isTransfer) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index e05492d000..b8828d7318 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -40,7 +40,7 @@ import to.bitkit.viewmodels.ActivityListViewModel fun AllActivityScreen( viewModel: ActivityListViewModel, onBack: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() @@ -90,7 +90,7 @@ private fun AllActivityScreenContent( onBackClick: () -> Unit, onTagClick: () -> Unit, onDateRangeClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 6c4394e81b..ebbf830c1e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -28,7 +28,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +47,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -82,8 +82,8 @@ fun ActivityListGrouped( when (item) { is String -> "header_$item" is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" } else -> "item_$index" @@ -120,7 +120,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) @@ -165,7 +165,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -180,8 +180,8 @@ fun LazyListScope.activityListGroupedItems( when (item) { is String -> "header_$item" is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" } else -> "item_$index" @@ -217,7 +217,7 @@ fun LazyListScope.activityListGroupedItems( item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 5af0ba3173..2a220d64cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -32,7 +32,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -51,7 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a26a324811..c002550f3c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -32,10 +32,10 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer -import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -65,7 +65,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (Activity) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -101,7 +101,7 @@ fun ActivityRow( LaunchedEffect(item) { isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(item.v1.txId) + activityListViewModel.isCpfpChildTransaction(item.v1.txId, item.walletId()) } else { false } @@ -111,7 +111,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..2fe8aa509c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity @@ -18,13 +19,17 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.rawId +import to.bitkit.ext.walletId +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo @@ -46,6 +51,9 @@ class ActivityDetailViewModel @Inject constructor( private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() + private val _isTransactionDetailsLoading = MutableStateFlow(false) + val isTransactionDetailsLoading = _isTransactionDetailsLoading.asStateFlow() + private val _tags = MutableStateFlow>(persistentListOf()) val tags = _tags.asStateFlow() @@ -58,30 +66,30 @@ class ActivityDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { + val resolvedWalletId = walletId ?: WalletScope.default viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(activityLoadState = ActivityLoadState.Loading) } - activityRepo.getActivity(activityId) + activityRepo.getActivity(activityId, resolvedWalletId) .onSuccess { activity -> if (activity != null) { this@ActivityDetailViewModel.activity = activity - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) } + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Success(activity), + isHardwareActivity = activity.isFromHardwareWallet(), + ) + } loadTags() - observeActivityChanges(activityId) + observeActivityChanges(activityId, resolvedWalletId) } else { - loadHwWalletActivity(activityId) + loadHwWalletActivity(activityId, resolvedWalletId) } } - .onFailure { e -> - Logger.error("Failed to load activity $activityId", e, TAG) - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - e.message ?: context.getString(R.string.wallet__activity_error_load_failed) - ) - ) - } + .onFailure { + Logger.warn("Failed to load activity '$activityId' from Core", it, context = TAG) + loadHwWalletActivity(activityId, resolvedWalletId, it) } } } @@ -92,73 +100,71 @@ class ActivityDetailViewModel @Inject constructor( _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } activity = null _tags.update { persistentListOf() } + clearTransactionDetails() } - private fun loadHwWalletActivity(activityId: String) { - val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } + private fun loadHwWalletActivity( + activityId: String, + walletId: String, + coreError: Throwable? = null, + observeChanges: Boolean = true, + ) { + val hwActivity = hwWalletRepo.activities.value.find { + it.rawId() == activityId && it.walletId() == walletId + } if (hwActivity != null) { activity = hwActivity _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) } - observeHwWalletActivityChanges(activityId) + if (observeChanges) observeActivityChanges(activityId, walletId) } else { _uiState.update { it.copy( activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) + coreError?.message ?: context.getString(R.string.wallet__activity_error_not_found) ) ) } } } - private fun observeHwWalletActivityChanges(activityId: String) { + private fun observeActivityChanges(activityId: String, walletId: String) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { - hwWalletRepo.activities.collect { activities -> - val updatedActivity = activities.find { it.rawId() == activityId } ?: return@collect - activity = updatedActivity - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Success(updatedActivity), - isHardwareActivity = true, - ) - } - } - } - } - - private fun observeActivityChanges(activityId: String) { - observeJob?.cancel() - observeJob = viewModelScope.launch(bgDispatcher) { - activityRepo.activitiesChanged.collect { - reloadActivity(activityId) + combine(activityRepo.activitiesChanged, hwWalletRepo.activities) { _, _ -> Unit }.collect { + reloadActivity(activityId, walletId) } } } - private suspend fun reloadActivity(activityId: String) { - activityRepo.getActivity(activityId) + private suspend fun reloadActivity(activityId: String, walletId: String) { + activityRepo.getActivity(activityId, walletId) .onSuccess { updatedActivity -> if (updatedActivity != null) { activity = updatedActivity _uiState.update { - it.copy(activityLoadState = ActivityLoadState.Success(updatedActivity)) + it.copy( + activityLoadState = ActivityLoadState.Success(updatedActivity), + isHardwareActivity = updatedActivity.isFromHardwareWallet(), + ) } loadTags() + } else { + loadHwWalletActivity(activityId, walletId, observeChanges = false) } } - .onFailure { error -> - Logger.warn("Failed to reload activity $activityId", error, context = TAG) + .onFailure { + Logger.warn("Failed to reload activity '$activityId'", it, context = TAG) // Keep showing the last known state on reload failure } } fun loadTags() { - val id = activity?.rawId() ?: return + val currentActivity = activity ?: return + val id = currentActivity.rawId() viewModelScope.launch(bgDispatcher) { - activityRepo.getActivityTags(id) + activityRepo.getActivityTags(id, currentActivity.walletId()) .onSuccess { activityTags -> _tags.update { activityTags.toImmutableList() } } @@ -170,9 +176,10 @@ class ActivityDetailViewModel @Inject constructor( } fun removeTag(tag: String) { - val id = activity?.rawId() ?: return + val currentActivity = activity ?: return + val id = currentActivity.rawId() viewModelScope.launch(bgDispatcher) { - activityRepo.removeTagsFromActivity(id, listOf(tag)) + activityRepo.removeTagsFromActivity(id, listOf(tag), currentActivity.walletId()) .onSuccess { loadTags() } @@ -183,9 +190,10 @@ class ActivityDetailViewModel @Inject constructor( } fun addTag(tag: String) { - val id = activity?.rawId() ?: return + val currentActivity = activity ?: return + val id = currentActivity.rawId() viewModelScope.launch(bgDispatcher) { - activityRepo.addTagsToActivity(id, listOf(tag)) + activityRepo.addTagsToActivity(id, listOf(tag), currentActivity.walletId()) .onSuccess { settingsStore.addLastUsedTag(tag) loadTags() @@ -197,13 +205,14 @@ class ActivityDetailViewModel @Inject constructor( } fun detachContact() { - val id = activity?.rawId() ?: return + val currentActivity = activity ?: return + val id = currentActivity.rawId() viewModelScope.launch(bgDispatcher) { activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id) + reloadActivity(id, currentActivity.walletId()) }.onFailure { Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) } @@ -211,8 +220,10 @@ class ActivityDetailViewModel @Inject constructor( } fun fetchTransactionDetails(txid: String) { + val walletId = activity?.walletId() ?: return viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) + _isTransactionDetailsLoading.update { true } + activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } } @@ -220,11 +231,13 @@ class ActivityDetailViewModel @Inject constructor( Logger.error("fetchTransactionDetails error", e, context = TAG) _txDetails.update { null } } + _isTransactionDetailsLoading.update { false } } } fun clearTransactionDetails() { _txDetails.update { null } + _isTransactionDetailsLoading.update { false } } fun onClickBoost() { @@ -236,10 +249,10 @@ class ActivityDetailViewModel @Inject constructor( } suspend fun getBoostTxDoesExist(boostTxIds: List): ImmutableMap = - activityRepo.getBoostTxDoesExist(boostTxIds).toImmutableMap() + activityRepo.getBoostTxDoesExist(boostTxIds, activity?.walletId() ?: WalletScope.default).toImmutableMap() suspend fun isCpfpChildTransaction(txId: String): Boolean { - return activityRepo.isCpfpChildTransaction(txId) + return activityRepo.isCpfpChildTransaction(txId, activity?.walletId() ?: WalletScope.default) } suspend fun findOrderForTransfer( @@ -284,6 +297,7 @@ class ActivityDetailViewModel @Inject constructor( data class Error(val message: String) : ActivityLoadState } + @Stable data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, val isHardwareActivity: Boolean = false, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index df932631a0..9d75eb641f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,13 +27,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo @@ -60,16 +64,17 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() private val _latestActivities = MutableStateFlow?>(null) - private val _localActivityIds = MutableStateFlow>(emptySet()) + private val _persistedActivityIds = MutableStateFlow>(emptySet()) + private val _persistedHardwareIds = MutableStateFlow>(emptySet()) // Merge the device's watch-only hardware-wallet activity into the home list, // newest first, capped at the same limit as the on-chain/lightning list. val latestActivities: StateFlow?> = combine( _latestActivities, hwWalletRepo.activities, - _localActivityIds, - ) { localActivities, hardwareActivities, localActivityIds -> - val visibleHardwareActivities = hardwareActivities.withoutLocalDuplicates(localActivityIds) + _persistedActivityIds, + ) { localActivities, hardwareActivities, persistedActivityIds -> + val visibleHardwareActivities = hardwareActivities.withoutPersistedDuplicates(persistedActivityIds) if (localActivities == null && visibleHardwareActivities.isEmpty()) { null } else { @@ -93,10 +98,9 @@ class ActivityListViewModel @Inject constructor( val hardwareIds: StateFlow> = combine( hwWalletRepo.activities, - _localActivityIds, - ) { activities, localActivityIds -> - activities.withoutLocalDuplicates(localActivityIds) - .map { it.rawId() } + _persistedHardwareIds, + ) { activities, persistedHardwareIds -> + (activities.map { it.scopedId() } + persistedHardwareIds) .toImmutableSet() } .stateInScope(persistentSetOf()) @@ -144,11 +148,11 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, hwWalletRepo.activities, - _localActivityIds, - ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, localActivityIds -> + _persistedActivityIds, + ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, persistedActivityIds -> val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) fetchFilteredActivities(filters)?.let { activities -> - (activities + hardwareActivities.withoutLocalDuplicates(localActivityIds).filteredWith(filters)) + (activities + hardwareActivities.withoutPersistedDuplicates(persistedActivityIds).filteredWith(filters)) .sortedByDescending { it.timestamp() } } }.collect { activities -> @@ -156,10 +160,6 @@ class ActivityListViewModel @Inject constructor( } } - /** - * Watch-only hardware-wallet activities live outside the activity database, so the - * list filters are applied to them here. They carry no tags and are never transfers. - */ private fun List.filteredWith(filters: ActivityFilters): List { if (filters.tags.isNotEmpty() || filters.tab == ActivityTab.OTHER) return emptyList() @@ -181,17 +181,28 @@ class ActivityListViewModel @Inject constructor( } } - private fun List.withoutLocalDuplicates(localActivityIds: Set) = filterNot { - it.rawId() in localActivityIds + private fun List.withoutPersistedDuplicates(persistedActivityIds: Set) = filterNot { + it.scopedId() in persistedActivityIds } private suspend fun refreshActivityState() { - val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() + val all = activityRepo.getActivities(walletId = null, filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) - _localActivityIds.update { filtered.map { it.rawId() }.toSet() } + _persistedActivityIds.update { filtered.map { it.scopedId() }.toSet() } + _persistedHardwareIds.update { + filtered.filter { it.isFromHardwareWallet() }.map { it.scopedId() }.toSet() + } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } - _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } - _onchainActivities.update { filtered.filterIsInstance().toImmutableList() } + _lightningActivities.update { + filtered.filterIsInstance() + .filter { it.v1.walletId == WalletScope.default } + .toImmutableList() + } + _onchainActivities.update { + filtered.filterIsInstance() + .filter { it.v1.walletId == WalletScope.default } + .toImmutableList() + } } private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { @@ -202,6 +213,7 @@ class ActivityListViewModel @Inject constructor( } val activities = activityRepo.getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = txType, tags = filters.tags.takeIf { it.isNotEmpty() }?.toList(), @@ -223,8 +235,12 @@ class ActivityListViewModel @Inject constructor( } private suspend fun filterOutReplacedSentTransactions(activities: List): List { - val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() - return activities.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) } + val boostTxIdsByWallet = activities.map { it.walletId() }.distinct().associateWith { + activityRepo.getTxIdsInBoostTxIds(it) + } + return activities.filterNot { + it.isReplacedSentTransaction(boostTxIdsByWallet[it.walletId()].orEmpty()) + } } fun updateAvailableTags() { @@ -251,8 +267,8 @@ class ActivityListViewModel @Inject constructor( activityRepo.removeAllActivities() } - suspend fun isCpfpChildTransaction(txId: String): Boolean { - return activityRepo.isCpfpChildTransaction(txId) + suspend fun isCpfpChildTransaction(txId: String, walletId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId, walletId) } private fun Flow.stateInScope( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index abe1a655f5..623898529f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -97,6 +97,7 @@ import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.ext.watchUntil import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.FeeRate @@ -346,6 +347,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2517,9 +2519,14 @@ class AppViewModel @Inject constructor( } fun onClickActivityDetail() { - _transactionSheet.value.activityId?.let { + val transactionDetails = _transactionSheet.value + transactionDetails.activityId?.let { hideNewTransactionSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it))) + mainScreenEffect( + MainScreenEffect.Navigate( + Routes.ActivityDetail(it, transactionDetails.activityWalletId) + ) + ) return } @@ -2536,7 +2543,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideNewTransactionSheet() _transactionSheet.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) @@ -2560,7 +2567,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideSheet() _successSendUiState.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 74e779431f..0a57dc7b83 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -48,6 +48,7 @@ import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo @@ -458,6 +459,7 @@ class TransferViewModel @Inject constructor( feeRate: ULong = 0uL, txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, + activityWalletId: String = WalletScope.default, ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) transferRepo.createTransfer( @@ -474,6 +476,7 @@ class TransferViewModel @Inject constructor( txId = txId, fee = fee, feeRate = feeRate, + walletId = activityWalletId, ) } viewModelScope.launch { walletRepo.syncBalances() } @@ -808,6 +811,10 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) return@launch } + val walletId = hwWalletRepo.getWalletId(deviceId).getOrElse { + handleHardwareTransferFailure(it, deviceId) + return@launch + } signAndBroadcastHardwareFunding(order, deviceId, address) .onSuccess { result -> @@ -818,6 +825,7 @@ class TransferViewModel @Inject constructor( createTransferActivity = true, fee = result.miningFeeSats, feeRate = result.feeRate, + activityWalletId = walletId, ) }.onSuccess { pendingHwFundingBroadcast = null diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index b4d2eb069c..cd99d949b8 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -9,6 +9,7 @@ import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -20,6 +21,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.ConvertedAmount import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.test.BaseUnitTest @@ -74,7 +76,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), eq(WalletScope.default))).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -86,13 +88,13 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) - verify(activityRepo, never()).markActivityAsSeen(any()) + verify(activityRepo, never()).markActivityAsSeen(any(), eq(WalletScope.default)) val claimed = sut.claimPresentation(command) assertTrue(claimed) sut.recordPresentation(command) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", WalletScope.default) } @Test @@ -102,7 +104,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), eq(WalletScope.default))).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning( event = event, includeNotification = true, @@ -147,13 +149,13 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), eq(WalletScope.default)) val claimed = sut.claimPresentation(command) assertTrue(claimed) sut.recordPresentation(command) - verify(activityRepo).markOnchainActivityAsSeen("txid456") + verify(activityRepo).markOnchainActivityAsSeen("txid456", WalletScope.default) } @Test @@ -194,7 +196,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) - verify(activityRepo).markOnchainActivityAsSeen("txid789") + verify(activityRepo).markOnchainActivityAsSeen("txid789", WalletScope.default) } } @@ -212,7 +214,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut(command) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), any()) } @Test @@ -222,14 +224,14 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), eq(WalletScope.default))).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) sut(command) verify(activityRepo, never()).handleOnchainTransactionReceived(any(), any()) verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), any()) } @Test @@ -239,7 +241,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen("paymentId123")).thenReturn(true) + whenever(activityRepo.isActivitySeen("paymentId123", WalletScope.default)).thenReturn(true) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -247,7 +249,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) - verify(activityRepo, never()).markActivityAsSeen(any()) + verify(activityRepo, never()).markActivityAsSeen(any(), any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index caf7b134f4..9b652c548c 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +17,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsStore @@ -40,6 +42,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" const val ORDER_ID = "test-order-id" + const val HARDWARE_WALLET_ID = "hardware-wallet" } @Before @@ -67,7 +70,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { val hwActivity = Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + OnchainActivity.create( + walletId = HARDWARE_WALLET_ID, id = ACTIVITY_ID, txType = PaymentType.RECEIVED, txId = ACTIVITY_ID, @@ -78,10 +82,12 @@ class ActivityDetailViewModelTest : BaseUnitTest() { confirmed = true, ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + whenever { + activityRepo.getActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) + }.thenReturn(Result.success(null)) whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) - sut.loadActivity(ACTIVITY_ID) + sut.loadActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) val state = sut.uiState.value val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success @@ -91,14 +97,16 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `hardware wallet activity updates while loaded`() = test { - val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) - val updatedActivity = createTestActivity(ACTIVITY_ID, confirmed = true) + val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false, walletId = HARDWARE_WALLET_ID) + val updatedActivity = createTestActivity(ACTIVITY_ID, confirmed = true, walletId = HARDWARE_WALLET_ID) val hardwareActivities = MutableStateFlow(persistentListOf(initialActivity)) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(null)) + whenever { + activityRepo.getActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) + }.thenReturn(Result.success(null)) whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) - sut.loadActivity(ACTIVITY_ID) + sut.loadActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) val initialState = sut.uiState.value.activityLoadState assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) @@ -258,12 +266,41 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Error) } + @Test + fun `hardware tags and transaction details use the activity wallet scope`() = test { + val activity = createTestActivity(ACTIVITY_ID, walletId = HARDWARE_WALLET_ID) + val transactionDetails = mock() + whenever { + activityRepo.getActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) + }.thenReturn(Result.success(activity)) + whenever { + activityRepo.getActivityTags(ACTIVITY_ID, HARDWARE_WALLET_ID) + }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("cold"), HARDWARE_WALLET_ID) + }.thenReturn(Result.success(Unit)) + whenever { + activityRepo.getTransactionDetails(activity.v1.txId, HARDWARE_WALLET_ID) + }.thenReturn(Result.success(transactionDetails)) + + sut.loadActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) + sut.addTag("cold") + sut.fetchTransactionDetails(activity.v1.txId) + + verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("cold"), HARDWARE_WALLET_ID) + verify(activityRepo).getTransactionDetails(activity.v1.txId, HARDWARE_WALLET_ID) + assertEquals(transactionDetails, sut.txDetails.value) + assertFalse(sut.isTransactionDetailsLoading.value) + } + private fun createTestActivity( id: String, confirmed: Boolean = false, + walletId: String = "wallet0", ): Activity.Onchain { return Activity.Onchain( - v1 = OnchainActivity.create(walletId = "wallet0", + v1 = OnchainActivity.create( + walletId = walletId, id = id, txType = PaymentType.RECEIVED, txId = "tx-$id", diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index e811196278..4dc9365a43 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,7 +15,6 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -29,6 +28,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.WalletScope import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -63,7 +63,8 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } - private val baseOnchainActivity = OnchainActivity.create(walletId = "wallet0", + private val baseOnchainActivity = OnchainActivity.create( + walletId = "wallet0", id = "base_activity_id", txType = PaymentType.SENT, txId = "base_tx_id", @@ -159,7 +160,9 @@ class ActivityRepoTest : BaseUnitTest() { fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(payments)) - wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) + wheneverBlocking { + coreService.activity.getActivity(any(), eq(WalletScope.default)) + }.thenReturn(null) wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities( any>(), @@ -196,6 +199,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = any(), txType = any(), tags = any(), @@ -225,6 +229,7 @@ class ActivityRepoTest : BaseUnitTest() { val activities = listOf(testActivity) wheneverBlocking { coreService.activity.get( + walletId = WalletScope.default, filter = ActivityFilter.ALL, txType = PaymentType.RECEIVED, tags = listOf("tag1"), @@ -254,7 +259,9 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `getActivity returns activity when found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(testActivity) val result = sut.getActivity(activityId) @@ -263,80 +270,41 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { - val existing = createOnchainActivity( - id = "transfer-txid", - txId = "transfer-txid", - value = 50_000uL, - fee = 0uL, - feeRate = 2uL, - address = "bc1qlsp", - confirmed = false, - timestamp = 1_000uL, - isTransfer = true, - channelId = "channel-1", - isBoosted = true, - boostTxIds = listOf("boost-txid"), - contact = "contact", - ).v1 - val watcher = OnchainActivity.create(walletId = "wallet0", - id = "transfer-txid", - txType = PaymentType.SENT, - txId = "transfer-txid", - value = 49_000uL, - fee = 1_250uL, - address = "", - timestamp = 2_000uL, - confirmed = true, + fun `persistHardware replaces the wallet snapshot and notifies observers`() = test { + val walletId = "hardware-wallet" + val activity = createOnchainActivity().copy( + v1 = baseOnchainActivity.copy(walletId = walletId) ) - whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + whenever( + coreService.activity.replaceHardwareSnapshot( + walletId = walletId, + activities = listOf(activity), + transactionDetails = emptyList(), + ) + ).thenReturn(listOf(activity)) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardware(walletId, listOf(activity), emptyList()) - assertTrue(result.isSuccess) - val captor = argumentCaptor() - verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) - val updated = (captor.firstValue as Activity.Onchain).v1 - assertTrue(updated.confirmed) - assertEquals(2_000uL, updated.confirmTimestamp) - assertEquals(true, updated.doesExist) - assertEquals(50_000uL, updated.value) - assertEquals(1_250uL, updated.fee) - assertEquals(2uL, updated.feeRate) - assertEquals("bc1qlsp", updated.address) - assertEquals(true, updated.isTransfer) - assertEquals("channel-1", updated.channelId) - assertEquals(true, updated.isBoosted) - assertEquals(listOf("boost-txid"), updated.boostTxIds) - assertEquals("contact", updated.contact) - } - - @Test - fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create(walletId = "wallet0", - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, - confirmed = true, - ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + assertEquals(listOf(activity), result.getOrThrow()) + verify(coreService.activity).replaceHardwareSnapshot(walletId, listOf(activity), emptyList()) + } + + @Test + fun `deleteForWallet removes scoped Core activity`() = test { + whenever(coreService.activity.deleteByWalletId("hardware-wallet")).thenReturn(2u) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.deleteForWallet("hardware-wallet") assertTrue(result.isSuccess) - verify(coreService.activity, never()).update(any(), any()) - verify(coreService.activity, never()).insert(any()) - verify(coreService.activity, never()).upsert(any()) + verify(coreService.activity).deleteByWalletId("hardware-wallet") } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(null) val result = sut.getActivity(activityId) @@ -360,9 +328,10 @@ class ActivityRepoTest : BaseUnitTest() { boostTxIds = listOf(replacedTxId), contact = contactPublicKey, ) - whenever(coreService.activity.getTxIdsInBoostTxIds()).thenReturn(setOf(replacedTxId)) + whenever(coreService.activity.getTxIdsInBoostTxIds(WalletScope.default)).thenReturn(setOf(replacedTxId)) whenever( coreService.activity.get( + walletId = WalletScope.default, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -393,10 +362,13 @@ class ActivityRepoTest : BaseUnitTest() { txId = "replacement_tx_id", boostTxIds = listOf(replacedTxId), ) - whenever(coreService.activity.getActivity(replacedTxId)).thenReturn(null) - whenever(coreService.activity.getOnchainActivityByTxId(replacedTxId)).thenReturn(replacedActivity.v1) + whenever(coreService.activity.getActivity(replacedTxId, WalletScope.default)).thenReturn(null) + whenever( + coreService.activity.getOnchainActivityByTxId(replacedTxId, WalletScope.default) + ).thenReturn(replacedActivity.v1) whenever( coreService.activity.get( + walletId = WalletScope.default, filter = ActivityFilter.ONCHAIN, txType = null, tags = null, @@ -478,13 +450,19 @@ class ActivityRepoTest : BaseUnitTest() { // Mock update for the new activity wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) // Mock getActivity to return the new activity (for addTagsToActivity check) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(testActivity) // Mock tags retrieval from the old activity - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) + wheneverBlocking { + coreService.activity.tags(activityToDeleteId, WalletScope.default) + }.thenReturn(tagsMock) // Mock tags retrieval from the new activity (should be empty so all tags are considered new) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.tags(activityId, WalletScope.default) }.thenReturn(emptyList()) // Mock appendTags to add tags to the new activity - wheneverBlocking { coreService.activity.appendTags(activityId, tagsMock) }.thenReturn(Result.success(Unit)) + wheneverBlocking { + coreService.activity.appendTags(activityId, tagsMock, WalletScope.default) + }.thenReturn(Result.success(Unit)) val result = sut.replaceActivity(activityId, activityToDeleteId, testActivity) @@ -492,11 +470,11 @@ class ActivityRepoTest : BaseUnitTest() { // Verify the new activity is updated verify(coreService.activity).update(activityId, testActivity) // Verify tags are retrieved from the old activity - verify(coreService.activity).tags(activityToDeleteId) + verify(coreService.activity).tags(activityToDeleteId, WalletScope.default) // Verify tags are added to the new activity - verify(coreService.activity).appendTags(activityId, tagsMock) + verify(coreService.activity).appendTags(activityId, tagsMock, WalletScope.default) // Verify delete is NOT called - verify(coreService.activity, never()).delete(any()) + verify(coreService.activity, never()).delete(any(), any()) // Verify addActivityToDeletedList is NOT called verify(cacheStore, never()).addActivityToDeletedList(any()) } @@ -504,20 +482,20 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `deleteActivity deletes successfully`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.delete(activityId) }.thenReturn(true) + wheneverBlocking { coreService.activity.delete(activityId, WalletScope.default) }.thenReturn(true) wheneverBlocking { cacheStore.addActivityToDeletedList(activityId) }.thenReturn(Unit) val result = sut.deleteActivity(activityId) assertTrue(result.isSuccess) - verify(coreService.activity).delete(activityId) + verify(coreService.activity).delete(activityId, WalletScope.default) verify(cacheStore).addActivityToDeletedList(activityId) } @Test fun `deleteActivity fails when service returns false`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.delete(activityId) }.thenReturn(false) + wheneverBlocking { coreService.activity.delete(activityId, WalletScope.default) }.thenReturn(false) val result = sut.deleteActivity(activityId) @@ -552,7 +530,7 @@ class ActivityRepoTest : BaseUnitTest() { fun `insertActivityFromCjit returns true when newly inserted`() = test { val channel = createChannelDetails() val id = channel.fundingTxo?.txid.orEmpty() - wheneverBlocking { coreService.activity.getActivity(id) }.thenReturn(null) + wheneverBlocking { coreService.activity.getActivity(id, WalletScope.default) }.thenReturn(null) wheneverBlocking { coreService.activity.insert(any()) }.thenReturn(Unit) val result = sut.insertActivityFromCjit(cjitEntry = IcJitEntry.mock(), channel = channel) @@ -566,7 +544,7 @@ class ActivityRepoTest : BaseUnitTest() { fun `insertActivityFromCjit returns false when activity already exists`() = test { val channel = createChannelDetails() val id = channel.fundingTxo?.txid.orEmpty() - wheneverBlocking { coreService.activity.getActivity(id) }.thenReturn(testActivity) + wheneverBlocking { coreService.activity.getActivity(id, WalletScope.default) }.thenReturn(testActivity) val result = sut.insertActivityFromCjit(cjitEntry = IcJitEntry.mock(), channel = channel) @@ -592,25 +570,28 @@ class ActivityRepoTest : BaseUnitTest() { val newTags = listOf("tag2", "tag3", "tag4", "") // tag2 exists, empty string should be filtered val expectedNewTags = listOf("tag3", "tag4") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(testActivity) + wheneverBlocking { coreService.activity.tags(activityId, WalletScope.default) }.thenReturn(existingTags) wheneverBlocking { coreService.activity.appendTags( activityId, - expectedNewTags + expectedNewTags, + WalletScope.default, ) }.thenReturn(Result.success(Unit)) val result = sut.addTagsToActivity(activityId, newTags) assertTrue(result.isSuccess) - verify(coreService.activity).appendTags(activityId, expectedNewTags) + verify(coreService.activity).appendTags(activityId, expectedNewTags, WalletScope.default) } @Test fun `addTagsToActivity fails when activity not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { coreService.activity.getActivity(activityId, WalletScope.default) }.thenReturn(null) val result = sut.addTagsToActivity(activityId, listOf("tag1")) @@ -623,13 +604,15 @@ class ActivityRepoTest : BaseUnitTest() { val existingTags = listOf("tag1", "tag2") val duplicateTags = listOf("tag1", "tag2", "") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(testActivity) + wheneverBlocking { coreService.activity.tags(activityId, WalletScope.default) }.thenReturn(existingTags) val result = sut.addTagsToActivity(activityId, duplicateTags) assertTrue(result.isSuccess) - verify(coreService.activity, never()).appendTags(any(), any()) + verify(coreService.activity, never()).appendTags(any(), any(), any()) } @Test @@ -661,19 +644,23 @@ class ActivityRepoTest : BaseUnitTest() { val activityId = "activity123" val tagsToRemove = listOf("tag1", "tag2") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - wheneverBlocking { coreService.activity.dropTags(activityId, tagsToRemove) }.thenReturn(Unit) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(testActivity) + wheneverBlocking { + coreService.activity.dropTags(activityId, tagsToRemove, WalletScope.default) + }.thenReturn(Unit) val result = sut.removeTagsFromActivity(activityId, tagsToRemove) assertTrue(result.isSuccess) - verify(coreService.activity).dropTags(activityId, tagsToRemove) + verify(coreService.activity).dropTags(activityId, tagsToRemove, WalletScope.default) } @Test fun `removeTagsFromActivity fails when activity not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { coreService.activity.getActivity(activityId, WalletScope.default) }.thenReturn(null) val result = sut.removeTagsFromActivity(activityId, listOf("tag1")) @@ -684,7 +671,7 @@ class ActivityRepoTest : BaseUnitTest() { fun `getActivityTags returns tags successfully`() = test { val activityId = "activity123" val tags = listOf("tag1", "tag2", "tag3") - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(tags) + wheneverBlocking { coreService.activity.tags(activityId, WalletScope.default) }.thenReturn(tags) val result = sut.getActivityTags(activityId) @@ -771,6 +758,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +811,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +864,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +916,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -938,10 +929,16 @@ class ActivityRepoTest : BaseUnitTest() { }.thenReturn(listOf(existingActivity)) val tagsToCopy = listOf("tag1", "tag2") wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(existingActivity) - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsToCopy) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) - wheneverBlocking { coreService.activity.appendTags(activityId, tagsToCopy) }.thenReturn(Result.success(Unit)) + wheneverBlocking { + coreService.activity.getActivity(activityId, WalletScope.default) + }.thenReturn(existingActivity) + wheneverBlocking { + coreService.activity.tags(activityToDeleteId, WalletScope.default) + }.thenReturn(tagsToCopy) + wheneverBlocking { coreService.activity.tags(activityId, WalletScope.default) }.thenReturn(emptyList()) + wheneverBlocking { + coreService.activity.appendTags(activityId, tagsToCopy, WalletScope.default) + }.thenReturn(Result.success(Unit)) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) val result = sut.syncActivities() @@ -950,8 +947,8 @@ class ActivityRepoTest : BaseUnitTest() { // Verify replaceActivity was called (indirectly by checking the new activity was updated) verify(coreService.activity).update(eq(activityId), any()) // Verify tags were copied from old activity to new activity - verify(coreService.activity).tags(activityToDeleteId) - verify(coreService.activity).appendTags(activityId, tagsToCopy) + verify(coreService.activity).tags(activityToDeleteId, WalletScope.default) + verify(coreService.activity).appendTags(activityId, tagsToCopy, WalletScope.default) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } @@ -978,6 +975,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 73e23f56ee..9f7740d3cb 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx @@ -14,7 +15,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -54,6 +54,10 @@ import kotlin.time.Instant @Suppress("LargeClass") class HwWalletRepoTest : BaseUnitTest() { + private companion object { + const val HARDWARE_WALLET_ID = "hardware-wallet" + } + private val trezorRepo = mock() private val activityRepo = mock() private val hwWalletStore = mock() @@ -74,6 +78,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = HARDWARE_WALLET_ID, ) @Before @@ -90,9 +95,16 @@ class HwWalletRepoTest : BaseUnitTest() { val xpubs = invocation.getArgument>(0) "derived-${xpubs.values.sorted().joinToString()}" } - runBlocking { - whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) + whenever { + activityRepo.persistHardware( + any(), + any>(), + any>(), + ) + }.thenAnswer { + it.getArgument>(1) } + whenever { activityRepo.deleteForWallet(any()) }.thenReturn(Result.success(Unit)) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) } @@ -166,11 +178,15 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1, wallet.activities.size) assertEquals(1, sut.activities.value.size) assertEquals(Activity.Onchain::class, wallet.activities.single()::class) - verify(activityRepo).syncHardwareOnchainActivity((wallet.activities.single() as Activity.Onchain).v1) + verify(activityRepo).persistHardware( + walletId = HARDWARE_WALLET_ID, + activities = wallet.activities, + transactionDetails = emptyList(), + ) } @Test - fun `balances from multiple address-type watchers are summed per device`() = test { + fun `events from inactive address-type watchers are ignored`() = test { val sut = createRepo() watcherEvents.emit( @@ -193,9 +209,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) val wallet = sut.wallets.value.single() - assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.balanceSats) assertEquals(100uL, wallet.fundingBalanceSats) - assertEquals(150uL, sut.totalSats.value) + assertEquals(100uL, sut.totalSats.value) } @Test @@ -223,7 +239,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `merges duplicate tx activities from multiple address-type watchers`() = test { + fun `inactive address-type activity does not replace active watcher activity`() = test { val sut = createRepo() watcherEvents.emit( @@ -249,17 +265,19 @@ class HwWalletRepoTest : BaseUnitTest() { val activity = sut.wallets.value.single().activities.single() as Activity.Onchain assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) - assertEquals(150uL, sut.wallets.value.single().balanceSats) + assertEquals(100uL, activity.v1.value) + assertEquals(100uL, sut.wallets.value.single().balanceSats) } @Test - fun `merges duplicate tx activities across hardware wallets`() = test { + fun `same tx id remains scoped across hardware wallets`() = test { + val secondWalletId = "hardware-wallet-2" val secondDevice = device.copy( id = "dev2", path = "ble:CC:DD", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS2"), + walletId = secondWalletId, ) storeData.value = HwWalletData(knownDevices = listOf(device, secondDevice)) wheneverStartWatcher().thenReturn(Result.success(Unit)) @@ -278,7 +296,9 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + activities = listOf( + watcherActivity(amount = 50uL, txid = "shared", walletId = secondWalletId) + ), transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, @@ -286,14 +306,15 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - val activity = sut.activities.value.single() as Activity.Onchain + val activities = sut.activities.value.filterIsInstance() assertEquals(2, sut.wallets.value.size) - assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) + assertEquals(2, activities.size) + assertEquals(setOf(HARDWARE_WALLET_ID, secondWalletId), activities.map { it.v1.walletId }.toSet()) + assertEquals(setOf(100uL, 50uL), activities.map { it.v1.value }.toSet()) } @Test - fun `merges duplicate sent tx activities without subtracting fee twice`() = test { + fun `inactive sent activity does not change active watcher value or fee`() = test { val sut = createRepo() val fee = 1_000uL @@ -324,7 +345,7 @@ class HwWalletRepoTest : BaseUnitTest() { val activity = sut.wallets.value.single().activities.single() as Activity.Onchain assertEquals(PaymentType.SENT, activity.v1.txType) - assertEquals(60_000uL, activity.v1.value) + assertEquals(40_000uL, activity.v1.value) assertEquals(fee, activity.v1.fee) } @@ -512,6 +533,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() val received = mutableListOf() val job = launch { sut.receivedTxs.collect { received += it } } + runCurrent() // Baseline: full history delivered on watcher start must not emit. watcherEvents.emit( @@ -524,6 +546,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() assertEquals(0, received.size) // New inbound tx after the baseline emits once. @@ -540,7 +563,17 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + runCurrent() + assertEquals(listOf("t1", "t2"), sut.activities.value.map { (it as Activity.Onchain).v1.txId }) + verify(activityRepo, times(2)).persistHardware( + walletId = eq(HARDWARE_WALLET_ID), + activities = any(), + transactionDetails = any(), + ) + assertEquals( + listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL, walletId = HARDWARE_WALLET_ID)), + received, + ) // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( @@ -556,16 +589,69 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() assertEquals(1, received.size) job.cancel() } + @Test + fun `persistence failure keeps activity live and retries the received event`() = test { + val baseline = listOf(watcherActivity(amount = 100uL)) + val updated = baseline + watcherActivity(amount = 50uL, txid = "retry-receive") + whenever { + activityRepo.persistHardware(HARDWARE_WALLET_ID, baseline, emptyList()) + }.thenReturn(Result.success(baseline)) + whenever { + activityRepo.persistHardware(HARDWARE_WALLET_ID, updated, emptyList()) + }.thenReturn( + Result.failure(AppError("persist failed")), + Result.success(updated), + ) + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + runCurrent() + + watcherEvents.emit( + "dev1|nativeSegwit" to transactionsChanged(100uL, baseline) + ) + runCurrent() + watcherEvents.emit( + "dev1|nativeSegwit" to transactionsChanged(150uL, updated) + ) + runCurrent() + + assertEquals( + listOf("t1", "retry-receive"), + sut.activities.value.filterIsInstance().map { it.v1.txId }, + ) + assertTrue(received.isEmpty()) + + watcherEvents.emit( + "dev1|nativeSegwit" to transactionsChanged(150uL, updated) + ) + runCurrent() + + assertEquals( + listOf( + HwWalletReceivedTx( + txid = "retry-receive", + sats = 50uL, + walletId = HARDWARE_WALLET_ID, + ) + ), + received, + ) + job.cancel() + } + @Test fun `emits received tx once when multiple watchers report the same new tx`() = test { val sut = createRepo() val received = mutableListOf() val job = launch { sut.receivedTxs.collect { received += it } } + runCurrent() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -576,6 +662,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), @@ -585,6 +672,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.TAPROOT, ) ) + runCurrent() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -596,6 +684,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), @@ -606,8 +695,12 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.TAPROOT, ) ) + runCurrent() - assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL)), received) + assertEquals( + listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL, walletId = HARDWARE_WALLET_ID)), + received, + ) job.cancel() } @@ -777,6 +870,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) verify(trezorRepo).forgetDevice("dev1") } @@ -807,6 +901,20 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).forgetDevice(any()) } + @Test + fun `removeDevice keeps the device when scoped activity cleanup fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever { activityRepo.deleteForWallet(HARDWARE_WALLET_ID) } + .thenReturn(Result.failure(AppError("delete failed"))) + val sut = createRepo() + + val result = sut.removeDevice("dev1") + + assertTrue(result.isFailure) + verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) + verify(trezorRepo, never()).forgetDevice(any()) + } + @Test fun `removeDevice forgets every entry of a device paired over both transports`() = test { val bleEntry = device.copy(id = "ble1", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS")) @@ -1098,6 +1206,19 @@ class HwWalletRepoTest : BaseUnitTest() { total = total, ) + private fun transactionsChanged( + total: ULong, + activities: List, + ) = WatcherEvent.TransactionsChanged( + balance = walletBalance(total), + activities = activities, + transactionDetails = emptyList(), + txCount = activities.size.toUInt(), + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + + @Suppress("LongParameterList") private fun watcherActivity( amount: ULong, txid: String = "t1", @@ -1106,9 +1227,10 @@ class HwWalletRepoTest : BaseUnitTest() { timestamp: ULong? = 1_700_000_000uL, confirmations: UInt = 3u, fee: ULong = 0uL, + walletId: String = HARDWARE_WALLET_ID, ) = Activity.Onchain( OnchainActivity.create( - walletId = "wallet0", + walletId = walletId, id = txid, txType = txType, txId = txid, diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index f69cf75225..5f8b087b41 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -2,11 +2,13 @@ package to.bitkit.repositories import app.cash.turbine.test import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -32,6 +34,7 @@ import to.bitkit.models.TransferType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -185,6 +188,31 @@ class TransferRepoTest : BaseUnitTest() { // MARK: - markSettled + @Test + fun `createPendingToSpendingActivity uses the funding wallet scope`() = test { + val order = previewBtOrder() + + val result = sut.createPendingToSpendingActivity( + order = order, + txId = "hardware-funding-tx", + fee = 1_000uL, + feeRate = 2uL, + walletId = "hardware-wallet", + ) + + assertTrue(result.isSuccess) + verify(activityService).createSentOnchainActivityFromSendResult( + txid = "hardware-funding-tx", + address = order.payment?.onchain?.address.orEmpty(), + amount = order.feeSat, + fee = 1_000uL, + feeRate = 2uL, + isTransfer = true, + channelId = order.channel?.shortChannelId, + walletId = "hardware-wallet", + ) + } + @Test fun `markSettled successfully marks transfer as settled`() = test { val settledAt = setupClockNowMock() @@ -291,7 +319,8 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create(walletId = "wallet0", + val activity = OnchainActivity.create( + walletId = "wallet0", id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -307,7 +336,15 @@ class TransferRepoTest : BaseUnitTest() { whenever(lightningRepo.getChannels()).thenReturn(listOf(channelDetails)) whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(mock())) whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)).thenReturn(Result.success(null)) - whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid)).thenReturn(activity) + whenever( + activityService.get( + walletId = null, + filter = ActivityFilter.ONCHAIN, + search = fundingTxo.txid, + limit = 10u, + sortDirection = SortDirection.DESC, + ) + ).thenReturn(listOf(Activity.Onchain(activity))) val result = sut.syncTransferStates() @@ -609,7 +646,8 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( + walletId = "wallet0", id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = "sweep-txid", @@ -636,14 +674,15 @@ class TransferRepoTest : BaseUnitTest() { whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(true) whenever( activityService.get( - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull() + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), ) ) .thenReturn(listOf(Activity.Onchain(sweepActivity))) @@ -741,7 +780,8 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( + walletId = "wallet0", id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = sweepTxid, @@ -773,7 +813,15 @@ class TransferRepoTest : BaseUnitTest() { whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) whenever(activityService.hasOnchainActivityForTxid(sweepTxid)).thenReturn(true) - whenever(activityService.getOnchainActivityByTxId(sweepTxid)).thenReturn(sweepActivity) + whenever( + activityService.get( + walletId = null, + filter = ActivityFilter.ONCHAIN, + search = sweepTxid, + limit = 10u, + sortDirection = SortDirection.DESC, + ) + ).thenReturn(listOf(Activity.Onchain(sweepActivity))) whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) val result = sut.syncTransferStates() diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 59f38021c8..eda6245661 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -235,7 +235,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(lightningRepo).accelerateByCpfp(any(), any(), any()) verify(lightningRepo).syncAsync() verify(activityRepo).updateActivity(any(), any(), any()) - verify(activityRepo, never()).deleteActivity(any()) + verify(activityRepo, never()).deleteActivity(any(), any()) } // region estimateTime dynamic tier tests diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 57e7fbb6f0..856b72ca04 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -34,7 +35,12 @@ class ActivityListViewModelTest : BaseUnitTest() { private val settingsStore = mock() private val dbActivity = onchainActivity(id = "db1", txType = PaymentType.SENT, timestamp = 200uL) - private val hwActivity = onchainActivity(id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL) + private val hwActivity = onchainActivity( + id = "hw1", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = "hardware-wallet", + ) private lateinit var hardwareActivities: MutableStateFlow> @Before @@ -43,7 +49,7 @@ class ActivityListViewModelTest : BaseUnitTest() { whenever(activityRepo.state).thenReturn(MutableStateFlow(ActivityState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) whenever { activityRepo.syncActivities() }.thenReturn(Result.success(Unit)) - whenever { activityRepo.getTxIdsInBoostTxIds() }.thenReturn(emptySet()) + whenever { activityRepo.getTxIdsInBoostTxIds(any()) }.thenReturn(emptySet()) whenever { activityRepo.getActivities( anyOrNull(), @@ -54,6 +60,7 @@ class ActivityListViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) }.thenReturn(Result.success(listOf(dbActivity))) whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) @@ -101,27 +108,38 @@ class ActivityListViewModelTest : BaseUnitTest() { val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(setOf("hardware-wallet:hw1"), sut.hardwareIds.value) job.cancel() } @Test - fun `hardware duplicates of local activities are excluded`() = test { + fun `same raw id in different wallets remains visible`() = test { hardwareActivities.value = persistentListOf( hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), + onchainActivity( + id = "db1", + txType = PaymentType.RECEIVED, + timestamp = 300uL, + walletId = "hardware-wallet", + ), ) val sut = createViewModel() val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(listOf("db1", "db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals(setOf("hardware-wallet:db1", "hardware-wallet:hw1"), sut.hardwareIds.value) job.cancel() } - private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + private fun onchainActivity( + id: String, + txType: PaymentType, + timestamp: ULong, + walletId: String = "wallet0", + ) = Activity.Onchain( + OnchainActivity.create( + walletId = walletId, id = id, txType = txType, txId = id, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 53d2352c01..aaa4ef500b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -343,13 +343,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.mainScreenEffect.test { advanceUntilIdle() - hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL)) + hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL, walletId = "hardware-wallet")) advanceUntilIdle() assertEquals(txId, sut.transactionSheet.value.activityId) sut.onClickActivityDetail() - assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId)), awaitItem()) + assertEquals( + MainScreenEffect.Navigate(Routes.ActivityDetail(txId, "hardware-wallet")), + awaitItem(), + ) } verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 6ed2be0632..f126969000 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -103,6 +103,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) whenever(context.getString(any())).thenReturn("") whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever { hwWalletRepo.getWalletId(DEVICE_ID) }.thenReturn(Result.success(HARDWARE_WALLET_ID)) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -586,6 +587,7 @@ class TransferViewModelTest : BaseUnitTest() { eq(TXID), eq(MINING_FEE), eq(FEE_RATE), + eq(HARDWARE_WALLET_ID), ) verify(hwWalletRepo).ensureConnected(DEVICE_ID) } @@ -1133,7 +1135,13 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) verify(hwWalletRepo, times(2)).broadcastFunding(signed) verify(cacheStore, times(2)).addPaidOrder(order.id, TXID) - verify(transferRepo).createPendingToSpendingActivity(order, TXID, MINING_FEE, FEE_RATE) + verify(transferRepo).createPendingToSpendingActivity( + order, + TXID, + MINING_FEE, + FEE_RATE, + HARDWARE_WALLET_ID, + ) } @Test @@ -1284,6 +1292,7 @@ class TransferViewModelTest : BaseUnitTest() { const val CONNECTION_ISSUE_DESCRIPTION = "Please check your connection." const val CONNECT_TITLE = "Connect Device" const val CONNECT_DESCRIPTION = "Check the hardware device and try again." + const val HARDWARE_WALLET_ID = "hardware-wallet" const val RECONNECT_TITLE = "Reconnect Hardware Device" const val RECONNECT_DESCRIPTION = "Please reconnect your hardware device." const val XPUB = "zpub-test" diff --git a/changelog.d/next/1044.changed.md b/changelog.d/next/1044.changed.md new file mode 100644 index 0000000000..a96218483f --- /dev/null +++ b/changelog.d/next/1044.changed.md @@ -0,0 +1 @@ +Hardware wallet transactions now keep wallet-scoped activity, tags, details, and transfer history in the unified Activity list. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 12d6411d6e..bf20c328e1 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,13 +60,14 @@ Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `activity-blue-icons.xml` | Wallet-scoped hardware activity, blue icons, unified list, and tab filters | +| `activity-detail-hw-tags.xml` | Persisted hardware tags and Explore inputs/outputs | | `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect; physical-device chooser path noted separately | | `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, full connect flow (Intro → Searching → Found → Paired → Finish) re-pairs | | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, rename sheet, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | -| `transfer-to-spending.xml` | Happy-path transfer amount → sign → processing flow with a valid amount below the cap | +| `transfer-to-spending.xml` | Happy-path transfer plus one scoped hardware Transfer activity | | `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | | `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | @@ -101,6 +102,10 @@ out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, send to an address generated via Dev Settings → Trezor → Get Address, then mine a block with `./bitcoin-cli`. +The activity journeys use a mixed fixture with two confirmed hardware receives carrying +distinct amounts and one hardware Transfer To Spending transaction. This proves +ordering, tab filtering, tags, inputs/outputs, transfer metadata, and row uniqueness together. + For transfer-to-spending QA, explicitly cover the LSP cap boundary: the hardware wallet balance can be much larger than the displayed AVAILABLE amount because MAX is capped by Blocktank channel headroom. After signing, decode the funding transaction and compare the diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c0..67a80ee73e 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,39 +1,39 @@ - Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the current watch-only - activity detail fallback until Core-backed hardware activity support lands. Requires a - paired Bridge emulator whose wallet has at least one on-chain transaction (run - connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). + Verifies hardware wallet on-chain activity in the unified home and All Activity lists, + with wallet-scoped blue icons and tab filters. Requires a paired Bridge emulator with at + least two confirmed receives with distinct amounts and one Transfer To Spending activity. Launch the Bitkit app and go to the wallet home screen - Verify the recent activity list contains at least one item whose circular icon is blue instead of orange + Verify the recent activity list contains at least one blue hardware activity row, then tap "Show All" beneath the activity list - Tap the first activity item with a blue icon + Verify All Activity contains two blue received rows with distinct amounts and one blue Transfer row with subtitle "From Savings" - Verify an activity detail screen opens showing a blue icon and an on-chain amount + Verify the blue Transfer row appears exactly once and no default-wallet row repeats its amount or transaction - Navigate back, then tap "Show All" beneath the activity list + Tap the blue Transfer row with subtitle "From Savings" - Verify the All Activity list also contains items with blue icons + Verify the detail screen shows a blue hardware icon, title "From Savings", and "TO SPENDING", then navigate back - Tap the "Sent" tab and verify no blue-icon item with a received (downward) arrow is listed + Tap the "Sent" tab and verify the blue received rows and blue Transfer row are not listed - Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions + Tap the "Received" tab and verify both distinct blue received rows are listed while the blue Transfer row is absent - Apply any tag filter if a tag exists, and verify blue-icon hardware items disappear from the filtered list; skip this step if no tags exist + Tap the "Other" tab and verify the blue Transfer row is listed while both blue received rows are absent + + + Tap the "All" tab and verify both blue received rows and the blue Transfer row are listed again diff --git a/journeys/hardware-wallet/activity-detail-hw-tags.xml b/journeys/hardware-wallet/activity-detail-hw-tags.xml new file mode 100644 index 0000000000..1951c50399 --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,42 @@ + + + Verifies wallet-scoped Core tags and Electrum transaction details for a hardware activity. + Requires a paired Bridge emulator with at least two confirmed hardware receives and the + tag "hwtest" absent from the selected activity before the run. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap "Show All" beneath the activity list + + + Tap a blue hardware received row that is not the Transfer row + + + Verify the activity detail screen shows a blue icon and the selected received amount + + + Tap "Add Tag", enter "hwtest", and confirm the tag + + + Verify a tag chip labelled "hwtest" is shown + + + Navigate back, reopen the same blue hardware row, and verify the "hwtest" tag is still shown + + + Tap "Explore" + + + Verify the Activity Explorer screen shows an Inputs section and an Outputs section, each with at least one entry + + + Navigate back to All Activity, open the tag filter, and select "hwtest" + + + Verify the tagged blue hardware activity remains listed and the other untagged hardware rows are absent + + + diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 4c628a7b4b..adf2e0ced0 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -1,23 +1,27 @@ Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With - Your Device -> Transaction Signed -> Processing Payment. The funding send is signed on the - Bridge emulator device, so no physical Trezor is required. Requires a paired Bridge emulator - (run connect-home-tile.xml first) whose native-segwit account holds spendable regtest funds, - and the bitkit-docker stack (Blocktank + regtest) running so the channel order can be created - and funded. Mirrors the on-chain Transfer To Spending flow, swapping local signing for the - device. + Your Device -> Transaction Signed -> Processing Payment, then verifies the new transfer is + represented once in the hardware wallet's Core activity scope. Requires a paired Bridge + emulator whose native-segwit account holds spendable regtest funds and at least one older + hardware receive row with a distinct amount. Launch the Bitkit app and go to the wallet home screen + + Verify the recent activity list contains an older blue hardware received row with a distinct amount + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad @@ -31,7 +35,10 @@ Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and wait while the Bridge emulator composes, signs and broadcasts the funding transaction + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") + + + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration @@ -39,5 +46,14 @@ Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears + + Tap "Continue Using Bitkit" and verify the app returns to the wallet home screen + + + Verify the recent activity list contains exactly one new blue hardware Transfer row with subtitle "From Savings", above the older blue received row + + + Tap the new blue Transfer row and verify its detail screen shows "TO SPENDING" + From 64cf108e5e94cdc7b21b40751621789a67894ff8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 23 Jul 2026 19:45:24 +0200 Subject: [PATCH 2/2] refactor: polish hardware activity handling --- .../main/java/to/bitkit/models/KnownDevice.kt | 1 - .../to/bitkit/repositories/ActivityRepo.kt | 25 ++++++++----------- .../to/bitkit/repositories/HwWalletRepo.kt | 2 +- .../to/bitkit/repositories/TransferRepo.kt | 3 ++- .../java/to/bitkit/services/CoreService.kt | 2 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 12 ++++----- .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +-- .../wallets/activity/ActivityExploreScreen.kt | 13 +++++----- .../viewmodels/ActivityDetailViewModel.kt | 12 ++++----- .../viewmodels/ActivityListViewModel.kt | 6 ++--- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++--- .../NotifyPaymentReceivedHandlerTest.kt | 10 ++++---- .../ActivityDetailViewModelTest.kt | 2 +- .../bitkit/repositories/ActivityRepoTest.kt | 22 +++++++++++++--- .../bitkit/repositories/HwWalletRepoTest.kt | 10 ++++---- changelog.d/next/1044.changed.md | 2 +- 16 files changed, 71 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt index 4429c2a20c..6b71f0f0ed 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -17,6 +17,5 @@ data class KnownDevice( val xpubs: Map = emptyMap(), /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ val customLabel: String? = null, - /** Stable Bitkit Core wallet scope for hardware activity metadata. */ val walletId: String = "", ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 69905df9bd..0c7c11d9e8 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -218,13 +218,13 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun persistHardware( + suspend fun persistHwSnapshot( walletId: String, activities: List, transactionDetails: List, ): Result> = withContext(bgDispatcher) { runSuspendCatching { - val persistedActivities = coreService.activity.replaceHardwareSnapshot( + val persistedActivities = coreService.activity.replaceHwSnapshot( walletId = walletId, activities = activities, transactionDetails = transactionDetails, @@ -371,7 +371,7 @@ class ActivityRepo @Inject constructor( limit: UInt? = null, sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { coreService.activity.get( walletId = walletId, filter = filter, @@ -404,7 +404,7 @@ class ActivityRepo @Inject constructor( id: String, walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { coreService.activity.getActivity(id, walletId) }.onFailure { Logger.error("getActivity error for ID: $id", it, context = TAG) @@ -642,14 +642,11 @@ class ActivityRepo @Inject constructor( id: String, walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { val deleted = coreService.activity.delete(id, walletId) - if (deleted) { - cacheStore.addActivityToDeletedList(id) - notifyActivitiesChanged() - } else { - return@withContext Result.failure(Exception("Activity not deleted")) - } + check(deleted) { "Activity not deleted" } + cacheStore.addActivityToDeletedList(id) + notifyActivitiesChanged() }.onFailure { Logger.error("deleteActivity error for ID: $id", it, context = TAG) } @@ -737,7 +734,7 @@ class ActivityRepo @Inject constructor( tags: List, walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { checkNotNull(coreService.activity.getActivity(activityId, walletId)) { "Activity with ID $activityId not found" } @@ -786,7 +783,7 @@ class ActivityRepo @Inject constructor( walletId: String = WalletScope.default, ): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { checkNotNull(coreService.activity.getActivity(activityId, walletId)) { "Activity with ID $activityId not found" } @@ -806,7 +803,7 @@ class ActivityRepo @Inject constructor( activityId: String, walletId: String = WalletScope.default, ): Result> = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index bc3b36f1d7..5021bb4a5f 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -415,7 +415,7 @@ class HwWalletRepo @Inject constructor( activities.map { it.scopedId() }.toSet() } - val persistedActivities = activityRepo.persistHardware( + val persistedActivities = activityRepo.persistHwSnapshot( walletId = walletId, activities = activities, transactionDetails = transactionDetails, diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index e40f47292b..6a5ca5fb73 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -18,6 +18,7 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.channelId +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.latestSpendingTxid import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType @@ -275,7 +276,7 @@ class TransferRepo @Inject constructor( .let { matches -> matches.firstOrNull { it.v1.isTransfer } ?: matches.firstOrNull { - it.v1.walletId != WalletScope.default && it.v1.txType == PaymentType.SENT + it.isFromHardwareWallet() && it.v1.txType == PaymentType.SENT } ?: matches.firstOrNull() }?.v1 ?: return diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9fd523c26f..63e890bb77 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -290,7 +290,7 @@ class ActivityService( upsertActivities(activities) } - suspend fun replaceHardwareSnapshot( + suspend fun replaceHwSnapshot( walletId: String, activities: List, transactionDetails: List, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a350ee7179..b787704059 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1003,7 +1003,7 @@ private fun NavGraphBuilder.home( isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1022,7 +1022,7 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1042,7 +1042,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1059,7 +1059,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, ) } } @@ -1217,7 +1217,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, ) } } @@ -1865,7 +1865,7 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(activity: Activity) = navigateTo( +fun NavController.navToActivityDetail(activity: Activity) = navigateTo( Routes.ActivityDetail( id = activity.rawId(), walletId = activity.walletId(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 7c8e4d1fc5..2f9f780e8c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -150,8 +150,8 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView +import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo -import to.bitkit.ui.navigateToActivityItem import to.bitkit.ui.navigateToAllActivity import to.bitkit.ui.navigateToProfile import to.bitkit.ui.navigateToTransferFunding @@ -379,7 +379,7 @@ fun HomeScreen( onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, + onNavigateToActivityItem = { rootNavController.navToActivityDetail(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index ed039564cc..12d022ca1a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -136,8 +136,7 @@ fun ActivityExploreScreen( val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() - val isTransactionDetailsLoading by - detailViewModel.isTransactionDetailsLoading.collectAsStateWithLifecycle() + val isTxDetailsLoading by detailViewModel.isTxDetailsLoading.collectAsStateWithLifecycle() var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { @@ -168,7 +167,7 @@ fun ActivityExploreScreen( item = item, isHardware = uiState.isHardwareActivity, txDetails = txDetails, - isTransactionDetailsLoading = isTransactionDetailsLoading, + isTxDetailsLoading = isTxDetailsLoading, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -193,7 +192,7 @@ private fun ActivityExploreContent( item: Activity, isHardware: Boolean = false, txDetails: TransactionDetails? = null, - isTransactionDetailsLoading: Boolean = false, + isTxDetailsLoading: Boolean = false, boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, @@ -227,7 +226,7 @@ private fun ActivityExploreContent( onchain = item, onCopy = onCopy, txDetails = txDetails, - isTransactionDetailsLoading = isTransactionDetailsLoading, + isTxDetailsLoading = isTxDetailsLoading, boostTxDoesExist = boostTxDoesExist, ) Spacer(modifier = Modifier.weight(1f)) @@ -290,7 +289,7 @@ private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, onCopy: (String) -> Unit, txDetails: TransactionDetails?, - isTransactionDetailsLoading: Boolean, + isTxDetailsLoading: Boolean, boostTxDoesExist: Map = emptyMap(), ) { val txId = onchain.v1.txId @@ -331,7 +330,7 @@ private fun ColumnScope.OnchainDetails( } }, ) - } else if (isTransactionDetailsLoading && !onchain.v1.isTransfer) { + } else if (isTxDetailsLoading && !onchain.v1.isTransfer) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 2fe8aa509c..0f2845777d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -51,8 +51,8 @@ class ActivityDetailViewModel @Inject constructor( private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() - private val _isTransactionDetailsLoading = MutableStateFlow(false) - val isTransactionDetailsLoading = _isTransactionDetailsLoading.asStateFlow() + private val _isTxDetailsLoading = MutableStateFlow(false) + val isTxDetailsLoading = _isTxDetailsLoading.asStateFlow() private val _tags = MutableStateFlow>(persistentListOf()) val tags = _tags.asStateFlow() @@ -88,7 +88,7 @@ class ActivityDetailViewModel @Inject constructor( } } .onFailure { - Logger.warn("Failed to load activity '$activityId' from Core", it, context = TAG) + Logger.warn("Failed to load activity '$activityId'", it, context = TAG) loadHwWalletActivity(activityId, resolvedWalletId, it) } } @@ -222,7 +222,7 @@ class ActivityDetailViewModel @Inject constructor( fun fetchTransactionDetails(txid: String) { val walletId = activity?.walletId() ?: return viewModelScope.launch(bgDispatcher) { - _isTransactionDetailsLoading.update { true } + _isTxDetailsLoading.update { true } activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } @@ -231,13 +231,13 @@ class ActivityDetailViewModel @Inject constructor( Logger.error("fetchTransactionDetails error", e, context = TAG) _txDetails.update { null } } - _isTransactionDetailsLoading.update { false } + _isTxDetailsLoading.update { false } } } fun clearTransactionDetails() { _txDetails.update { null } - _isTransactionDetailsLoading.update { false } + _isTxDetailsLoading.update { false } } fun onClickBoost() { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 9d75eb641f..1e2973998a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -74,7 +74,7 @@ class ActivityListViewModel @Inject constructor( hwWalletRepo.activities, _persistedActivityIds, ) { localActivities, hardwareActivities, persistedActivityIds -> - val visibleHardwareActivities = hardwareActivities.withoutPersistedDuplicates(persistedActivityIds) + val visibleHardwareActivities = hardwareActivities.excludingPersisted(persistedActivityIds) if (localActivities == null && visibleHardwareActivities.isEmpty()) { null } else { @@ -152,7 +152,7 @@ class ActivityListViewModel @Inject constructor( ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, persistedActivityIds -> val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) fetchFilteredActivities(filters)?.let { activities -> - (activities + hardwareActivities.withoutPersistedDuplicates(persistedActivityIds).filteredWith(filters)) + (activities + hardwareActivities.excludingPersisted(persistedActivityIds).filteredWith(filters)) .sortedByDescending { it.timestamp() } } }.collect { activities -> @@ -181,7 +181,7 @@ class ActivityListViewModel @Inject constructor( } } - private fun List.withoutPersistedDuplicates(persistedActivityIds: Set) = filterNot { + private fun List.excludingPersisted(persistedActivityIds: Set) = filterNot { it.scopedId() in persistedActivityIds } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 623898529f..3460a3e637 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2519,12 +2519,12 @@ class AppViewModel @Inject constructor( } fun onClickActivityDetail() { - val transactionDetails = _transactionSheet.value - transactionDetails.activityId?.let { + val details = _transactionSheet.value + details.activityId?.let { hideNewTransactionSheet() mainScreenEffect( MainScreenEffect.Navigate( - Routes.ActivityDetail(it, transactionDetails.activityWalletId) + Routes.ActivityDetail(it, details.activityWalletId) ) ) return diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index cd99d949b8..af7a08cdbc 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -119,13 +119,13 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertNotNull(paymentResult.notification) assertEquals("Payment Received", paymentResult.notification.title) - verify(activityRepo, never()).markActivityAsSeen(any()) + verify(activityRepo, never()).markActivityAsSeen(any(), eq(WalletScope.default)) val claimed = sut.claimPresentation(command) assertTrue(claimed) sut.recordPresentation(command) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", WalletScope.default) } @Test @@ -275,7 +275,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.markActivityAsSeen("paymentId123")) + whenever(activityRepo.markActivityAsSeen("paymentId123", WalletScope.default)) .thenThrow(IllegalStateException("activity store unavailable")) val command = NotifyPaymentReceived.Command.Lightning(event = event) @@ -296,7 +296,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(presented) assertEquals(1, presentationCount) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", WalletScope.default) assertFalse(sut.claimPresentation(command)) } @@ -313,7 +313,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val result = sut(command) assertTrue(result.getOrThrow() is NotifyPaymentReceived.Result.Skip) - verify(activityRepo, never()).isActivitySeen(any()) + verify(activityRepo, never()).isActivitySeen(any(), eq(WalletScope.default)) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 9b652c548c..0570679e04 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -290,7 +290,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("cold"), HARDWARE_WALLET_ID) verify(activityRepo).getTransactionDetails(activity.v1.txId, HARDWARE_WALLET_ID) assertEquals(transactionDetails, sut.txDetails.value) - assertFalse(sut.isTransactionDetailsLoading.value) + assertFalse(sut.isTxDetailsLoading.value) } private fun createTestActivity( diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 4dc9365a43..cae86430b7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -32,6 +33,7 @@ import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -270,23 +272,35 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `persistHardware replaces the wallet snapshot and notifies observers`() = test { + fun `getActivity rethrows cancellation`() = test { + val cancellation = CancellationException("cancelled") + whenever(coreService.activity.getActivity("activity123", WalletScope.default)).thenThrow(cancellation) + + val thrown = assertFailsWith { + sut.getActivity("activity123") + } + + assertEquals(cancellation.message, thrown.message) + } + + @Test + fun `persistHwSnapshot replaces the wallet snapshot and notifies observers`() = test { val walletId = "hardware-wallet" val activity = createOnchainActivity().copy( v1 = baseOnchainActivity.copy(walletId = walletId) ) whenever( - coreService.activity.replaceHardwareSnapshot( + coreService.activity.replaceHwSnapshot( walletId = walletId, activities = listOf(activity), transactionDetails = emptyList(), ) ).thenReturn(listOf(activity)) - val result = sut.persistHardware(walletId, listOf(activity), emptyList()) + val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) assertEquals(listOf(activity), result.getOrThrow()) - verify(coreService.activity).replaceHardwareSnapshot(walletId, listOf(activity), emptyList()) + verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 9f7740d3cb..207fc46e5e 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -96,7 +96,7 @@ class HwWalletRepoTest : BaseUnitTest() { "derived-${xpubs.values.sorted().joinToString()}" } whenever { - activityRepo.persistHardware( + activityRepo.persistHwSnapshot( any(), any>(), any>(), @@ -178,7 +178,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1, wallet.activities.size) assertEquals(1, sut.activities.value.size) assertEquals(Activity.Onchain::class, wallet.activities.single()::class) - verify(activityRepo).persistHardware( + verify(activityRepo).persistHwSnapshot( walletId = HARDWARE_WALLET_ID, activities = wallet.activities, transactionDetails = emptyList(), @@ -565,7 +565,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) runCurrent() assertEquals(listOf("t1", "t2"), sut.activities.value.map { (it as Activity.Onchain).v1.txId }) - verify(activityRepo, times(2)).persistHardware( + verify(activityRepo, times(2)).persistHwSnapshot( walletId = eq(HARDWARE_WALLET_ID), activities = any(), transactionDetails = any(), @@ -600,10 +600,10 @@ class HwWalletRepoTest : BaseUnitTest() { val baseline = listOf(watcherActivity(amount = 100uL)) val updated = baseline + watcherActivity(amount = 50uL, txid = "retry-receive") whenever { - activityRepo.persistHardware(HARDWARE_WALLET_ID, baseline, emptyList()) + activityRepo.persistHwSnapshot(HARDWARE_WALLET_ID, baseline, emptyList()) }.thenReturn(Result.success(baseline)) whenever { - activityRepo.persistHardware(HARDWARE_WALLET_ID, updated, emptyList()) + activityRepo.persistHwSnapshot(HARDWARE_WALLET_ID, updated, emptyList()) }.thenReturn( Result.failure(AppError("persist failed")), Result.success(updated), diff --git a/changelog.d/next/1044.changed.md b/changelog.d/next/1044.changed.md index a96218483f..7c42c552ac 100644 --- a/changelog.d/next/1044.changed.md +++ b/changelog.d/next/1044.changed.md @@ -1 +1 @@ -Hardware wallet transactions now keep wallet-scoped activity, tags, details, and transfer history in the unified Activity list. +Added support for tags and transaction details to hardware wallet activity.