diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 3a0bf991b5..dc337a825f 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -10,11 +10,13 @@ import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.data.serializers.AppCacheSerializer +import to.bitkit.ext.scopedActivityId import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BalanceState import to.bitkit.models.FxRate import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -103,11 +105,15 @@ class CacheStore @Inject constructor( } } - suspend fun addActivityToDeletedList(activityId: String) { + suspend fun addActivityToDeletedList( + activityId: String, + walletId: String = WalletScope.default, + ) { if (activityId.isBlank()) return - if (activityId in store.data.first().deletedActivities) return + val scopedId = scopedActivityId(walletId, activityId) + if (scopedId in store.data.first().deletedActivities) return store.updateData { - it.copy(deletedActivities = it.deletedActivities + activityId) + it.copy(deletedActivities = it.deletedActivities + scopedId) } } @@ -159,6 +165,10 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), ) { + fun isActivityDeleted(activityId: String, walletId: String): Boolean = + scopedActivityId(walletId, activityId) in deletedActivities || + walletId == WalletScope.default && activityId in deletedActivities + fun resetBip21() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "", onchainAddress = "") fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index f6527a5212..cd4b3a4af2 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -12,6 +12,17 @@ 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 scopedActivityId(walletId: String, activityId: String): String = "$walletId:$activityId" + +fun Activity.scopedId(): String = scopedActivityId(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..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 app-owned id for future wallet-scoped 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..7e2b6c2170 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -42,8 +42,11 @@ 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.ext.walletId 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 +181,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 +219,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 persistHwSnapshot( + walletId: String, + activities: List, + transactionDetails: List, + ): Result> = withContext(bgDispatcher) { + runSuspendCatching { + val persistedActivities = coreService.activity.replaceHwSnapshot( + walletId = walletId, + activities = activities, + transactionDetails = transactionDetails, ) - if (updated == existing) return@runCatching - coreService.activity.update(existing.id, Activity.Onchain(updated)) notifyActivitiesChanged() + persistedActivities }.onFailure { - Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + 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 delete activities for hardware wallet '$walletId'", it, context = TAG) } } @@ -260,31 +273,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 +362,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = WalletScope.default, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -339,8 +372,18 @@ class ActivityRepo @Inject constructor( limit: UInt? = null, sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { - runCatching { - coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + runSuspendCatching { + coreService.activity.get( + walletId = walletId, + filter = filter, + txType = txType, + tags = tags, + search = search, + minDate = minDate, + maxDate = maxDate, + limit = limit, + sortDirection = sortDirection, + ) }.onFailure { Logger.error( "getActivities error. Parameters:" + @@ -358,9 +401,12 @@ class ActivityRepo @Inject constructor( } } - suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { - runCatching { - coreService.activity.getActivity(id) + suspend fun getActivity( + id: String, + walletId: String = WalletScope.default, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + coreService.activity.getActivity(id, walletId) }.onFailure { Logger.error("getActivity error for ID: $id", it, context = TAG) } @@ -472,7 +518,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) { @@ -500,7 +546,7 @@ class ActivityRepo @Inject constructor( forceUpdate: Boolean = false, ): Result = withContext(bgDispatcher) { runCatching { - if (id in cacheStore.data.first().deletedActivities && !forceUpdate) { + if (cacheStore.data.first().isActivityDeleted(id, activity.walletId()) && !forceUpdate) { Logger.debug("Activity $id was deleted", context = TAG) return@withContext Result.failure( Exception( @@ -530,7 +576,7 @@ class ActivityRepo @Inject constructor( activity = activity ).onSuccess { Logger.debug("Activity $id updated with success. new data: $activity", context = TAG) - val tags = coreService.activity.tags(activityIdToDelete) + val tags = coreService.activity.tags(activityIdToDelete, WalletScope.default) addTagsToActivity(activityId = id, tags = tags) }.onFailure { Logger.error( @@ -593,15 +639,15 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - suspend fun deleteActivity(id: String): Result = withContext(bgDispatcher) { - runCatching { - val deleted = coreService.activity.delete(id) - if (deleted) { - cacheStore.addActivityToDeletedList(id) - notifyActivitiesChanged() - } else { - return@withContext Result.failure(Exception("Activity not deleted")) - } + suspend fun deleteActivity( + id: String, + walletId: String = WalletScope.default, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + val deleted = coreService.activity.delete(id, walletId) + check(deleted) { "Activity not deleted" } + cacheStore.addActivityToDeletedList(id, walletId) + notifyActivitiesChanged() }.onFailure { Logger.error("deleteActivity error for ID: $id", it, context = TAG) } @@ -609,7 +655,7 @@ class ActivityRepo @Inject constructor( suspend fun insertActivity(activity: Activity): Result = withContext(bgDispatcher) { runCatching { - if (activity.rawId() in cacheStore.data.first().deletedActivities) { + if (cacheStore.data.first().isActivityDeleted(activity.rawId(), activity.walletId())) { Logger.debug("Activity ${activity.rawId()} was deleted, skipping", context = TAG) return@withContext Result.failure(Exception("Activity ${activity.rawId()} was deleted")) } @@ -623,7 +669,7 @@ class ActivityRepo @Inject constructor( suspend fun upsertActivity(activity: Activity): Result = withContext(bgDispatcher) { runCatching { val id = activity.rawId() - if (id in cacheStore.data.first().deletedActivities) { + if (cacheStore.data.first().isActivityDeleted(id, activity.walletId())) { Logger.debug("Activity $id was deleted, skipping", context = TAG) return@withContext Result.failure(AppError("Activity $id was deleted")) } @@ -648,7 +694,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 +733,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" } + runSuspendCatching { + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - val existingTags = coreService.activity.tags(activityId) + val existingTags = coreService.activity.tags(activityId, walletId) val newTags = tags.filter { it.isNotBlank() && it !in existingTags } if (newTags.isNotEmpty()) { - coreService.activity.appendTags(activityId, newTags).getOrThrow() + coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() notifyActivitiesChanged() Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { @@ -729,12 +778,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" } + runSuspendCatching { + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - coreService.activity.dropTags(activityId, tags) + coreService.activity.dropTags(activityId, tags, walletId) notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { @@ -745,9 +800,12 @@ class ActivityRepo @Inject constructor( /** * Gets all tags for an activity */ - suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { - runCatching { - coreService.activity.tags(activityId) + suspend fun getActivityTags( + activityId: String, + walletId: String = WalletScope.default, + ): Result> = withContext(bgDispatcher) { + runSuspendCatching { + coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } @@ -769,11 +827,20 @@ class ActivityRepo @Inject constructor( suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { runCatching { coreService.activity.getAllActivitiesTags() + .filter { it.walletId == WalletScope.default } }.onFailure { Logger.error("getAllActivityTags error", it, context = TAG) } } + suspend fun getWalletIds(): Result> = withContext(bgDispatcher) { + runSuspendCatching { + coreService.activity.getWalletIds() + }.onFailure { + Logger.error("Failed to get activity wallet IDs", it, context = TAG) + } + } + suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { runCatching { coreService.activity.upsertList(payload.activities) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 564fb800df..68c280e445 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.WatcherEvent @@ -27,6 +28,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 +37,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 @@ -46,6 +50,7 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType @@ -56,9 +61,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ceil -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime /** * Production hardware-wallet business layer. Tracks paired Trezor devices as @@ -69,14 +72,12 @@ import kotlin.time.ExperimentalTime * and the underlying watcher transport. */ @Suppress("TooManyFunctions") -@OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, - private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -90,6 +91,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 +99,9 @@ class HwWalletRepo @Inject constructor( private val retryingWatcherStarts = mutableSetOf() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) + private val trackedWalletIds = mutableSetOf() + private val lastPersistedHwSnapshots = mutableMapOf() + private val persistedActivityIds = mutableMapOf>() private val emittedReceivedTxIds = mutableSetOf() private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) @@ -112,16 +117,21 @@ 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() + trackedWalletIds.clear() + lastPersistedHwSnapshots.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 +199,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 +306,32 @@ 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() + } + 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() + trackedWalletIds -= it + lastPersistedHwSnapshots -= it } - 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" } + 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 +405,57 @@ 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) + val receivedTxs = watcherMutex.withLock { + val walletId = activeWatcherWalletIds[watcherId] ?: return@withLock emptyList() + val activities = event.activities + .filter { it.walletId() == walletId } + .toImmutableList() + val transactionDetails = event.transactionDetails + .filter { it.walletId == walletId } + .toImmutableList() + val watcher = HwWatcherData( + deviceId = watcherId.toDeviceId(), + walletId = walletId, + addressType = watcherId.toAddressTypeKey(), + balanceSats = event.balance.total, + activities = activities, + ) + _watcherData.update { it + (watcherId to watcher) } + val previousIds = persistedActivityIds.getOrPut(watcherId) { + activities.map { it.scopedId() }.toSet() + } + val snapshot = HwSnapshot( + activities = activities, + transactionDetails = transactionDetails, + ) + val snapshotCacheKey = snapshot.toCacheKey() + lastPersistedHwSnapshots[walletId] + ?.takeIf { it.source == snapshotCacheKey } + ?.let { + _watcherData.update { data -> + data + (watcherId to watcher.copy(activities = it.activities)) + } + return@withLock emptyList() + } + + val persistedActivities = activityRepo.persistHwSnapshot( + walletId = walletId, + activities = activities, + transactionDetails = transactionDetails, + ).getOrElse { return@withLock emptyList() } + val immutablePersistedActivities = persistedActivities.toImmutableList() + lastPersistedHwSnapshots[walletId] = PersistedHwSnapshot( + source = snapshotCacheKey, + activities = immutablePersistedActivities, + ) + val persistedWatcher = watcher.copy(activities = immutablePersistedActivities) + val updatedWatcherData = _watcherData.value + (watcherId to persistedWatcher) + _watcherData.update { updatedWatcherData } + + persistedActivityIds[watcherId] = persistedActivities.map { it.scopedId() }.toSet() + buildReceivedTxs(previousIds, persistedActivities, updatedWatcherData) } - emitReceivedTxs(previous, activities, updatedWatcherData) + receivedTxs.forEach { _receivedTxs.emit(it) } } } } @@ -397,23 +464,24 @@ class HwWalletRepo @Inject constructor( * The first event after a watcher starts delivers the full transaction history; * treat it as the baseline so only transactions arriving while watching are emitted. */ - private suspend fun emitReceivedTxs( - previous: HwWatcherData?, + private fun buildReceivedTxs( + previousActivityIds: Set, activities: List, watcherData: Map, - ) { - if (previous == null) return - val knownTxIds = previous.activities.mapNotNull { activity -> - (activity as? Activity.Onchain)?.v1?.txId - }.toSet() + ): List { val mergedActivities = watcherData.values.toList().toMergedActivities() - activities.filterIsInstance() + return 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)) + .mapNotNull { onchain -> + val scopedId = onchain.scopedId() + if (scopedId in previousActivityIds || !emittedReceivedTxIds.add(scopedId)) return@mapNotNull null + val sats = mergedActivities.findOnchain(onchain.v1.txId, onchain.v1.walletId)?.v1?.value + ?: onchain.v1.value + HwWalletReceivedTx( + txid = onchain.v1.txId, + sats = sats, + walletId = onchain.v1.walletId, + ) } } @@ -434,68 +502,91 @@ 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, + ) { + val persistedWalletIds = activityRepo.getWalletIds().getOrDefault(emptySet()) + .filterNot { it == WalletScope.default } + .toSet() + watcherMutex.withLock { + val specs = knownDevices.toWatcherSpecs(watcherSettings.electrumUrl) + val desiredIds = specs.map { it.watcherId }.toSet() + val knownWalletIds = knownDevices.mapNotNull { it.resolvedWalletId() }.toSet() + trackedWalletIds += persistedWalletIds + val removedWalletIds = trackedWalletIds - knownWalletIds + trackedWalletIds += knownWalletIds + + 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) } + + removedWalletIds + .filterNot { it in activeWatcherWalletIds.values } + .forEach { walletId -> + activityRepo.deleteForWallet(walletId).onSuccess { + trackedWalletIds -= walletId + lastPersistedHwSnapshots -= walletId + } + } } } - 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 +595,9 @@ class HwWalletRepo @Inject constructor( scope.launch { delay(WATCHER_START_RETRY_DELAY) - retryingWatcherStarts -= watcherId + watcherMutex.withLock { + retryingWatcherStarts -= watcherId + } watcherSyncRequests.emit(Unit) } } @@ -514,7 +607,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 +658,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,7 +706,27 @@ private val KnownDevice.displayName: String private data class HwWatcherData( val deviceId: String, + val walletId: String, val addressType: String, val balanceSats: ULong, val activities: ImmutableList, ) + +private data class HwSnapshot( + val activities: ImmutableList, + val transactionDetails: ImmutableList, +) + +private fun HwSnapshot.toCacheKey() = copy( + activities = activities.map { + when (it) { + is Activity.Onchain if !it.v1.confirmed -> Activity.Onchain(it.v1.copy(timestamp = 0uL)) + else -> it + } + }.toImmutableList(), +) + +private data class PersistedHwSnapshot( + val source: HwSnapshot, + 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..350e51c867 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,18 @@ class TransferRepo @Inject constructor( } private suspend fun markActivityAsTransfer(txid: String, channelId: String) { - val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + val walletIds = listOf(WalletScope.default) + + (coreService.activity.getWalletIds() - WalletScope.default) + val activity = walletIds.mapNotNull { + coreService.activity.getOnchainActivityByTxId(txid, it) + } + .let { matches -> + matches.firstOrNull { it.isTransfer } + ?: matches.firstOrNull { + it.walletId != WalletScope.default && it.txType == PaymentType.SENT + } + ?: matches.firstOrNull() + } ?: 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..35b612d142 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 @@ -235,6 +241,32 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 +internal data class HwSnapshotMerge( + val toDelete: List, + val toUpsert: List, +) + +internal fun mergeHwSnapshot( + existing: List, + incoming: List, +): HwSnapshotMerge { + val incomingIds = incoming.map { it.rawId() }.toSet() + val toDelete = existing.filter { !it.v1.isTransfer && it.v1.id !in incomingIds } + val existingByTxId = existing.associateBy { it.v1.txId } + val toUpsert = incoming.map { activity -> + val onchain = activity as? Activity.Onchain ?: return@map activity + val stored = existingByTxId[onchain.v1.txId]?.v1 ?: return@map activity + Activity.Onchain( + onchain.v1.copy( + isTransfer = onchain.v1.isTransfer || stored.isTransfer, + channelId = onchain.v1.channelId ?: stored.channelId, + transferTxId = onchain.v1.transferTxId ?: stored.transferTxId, + ) + ) + } + return HwSnapshotMerge(toDelete = toDelete, toUpsert = toUpsert) +} + @Suppress("LargeClass", "TooManyFunctions") class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first @@ -243,13 +275,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 +292,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,6 +316,64 @@ class ActivityService( upsertActivities(activities) } + /** + * Replaces the complete persisted on-chain snapshot for [walletId]. + * + * Callers must merge every watcher for the wallet before invoking this method. The current + * hardware-wallet integration has one supported watcher address type per wallet. + */ + suspend fun replaceHwSnapshot( + 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 merge = mergeHwSnapshot(existing = existingActivities, incoming = activities) + merge.toDelete.forEach { + deleteActivityById(walletId = walletId, activityId = it.v1.id) + deleteTransactionDetails(walletId = walletId, txId = it.v1.txId) + } + + if (merge.toUpsert.isNotEmpty()) upsertActivities(merge.toUpsert) + 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, + ) + } + + suspend fun getWalletIds(): Set = ServiceQueue.CORE.background { + getActivities( + walletId = null, + filter = null, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ).map { it.walletId() }.toSet() + } + private fun mapToCoreTransactionDetails( txid: String, details: TransactionDetails, @@ -304,7 +397,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( - walletId = walletId, + walletId = defaultWalletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -312,15 +405,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 +433,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = defaultWalletId, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -360,23 +460,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 +513,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 +521,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 +536,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 +646,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 +686,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 +1026,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) @@ -976,7 +1085,7 @@ class ActivityService( ) } - if (onChain.id in cacheStore.data.first().deletedActivities && !forceUpdate) { + if (cacheStore.data.first().isActivityDeleted(onChain.id, onChain.walletId) && !forceUpdate) { Logger.verbose("Activity ${onChain.id} was already deleted, skipping", context = TAG) return } @@ -1101,15 +1210,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 +1535,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 +1546,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 +1566,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 +1601,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 +1622,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 c9ae9f1b64..38c2b4acaa 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 @@ -1006,7 +1009,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) @@ -1025,7 +1028,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) { @@ -1045,7 +1048,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1062,7 +1065,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, ) } } @@ -1220,7 +1223,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { navController.navToActivityDetail(it) }, ) } } @@ -1623,10 +1626,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)) @@ -1890,9 +1894,15 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navToActivityDetail(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)) @@ -2119,13 +2129,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..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)) }, @@ -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/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 1e6f6fe92e..927157033b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -124,7 +124,7 @@ class HomeViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) val hasActivityFlow = activityRepo.activitiesChanged.mapLatest { - activityRepo.getActivities(limit = 1u).getOrNull()?.isNotEmpty() == true + activityRepo.getActivities(walletId = null, limit = 1u).getOrNull()?.isNotEmpty() == true } viewModelScope.launch { 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 1415f214df..7867d607dd 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..2dd44a755a 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 isTxDetailsLoading by detailViewModel.isTxDetailsLoading.collectAsStateWithLifecycle() + val isTxDetailsUnavailable by detailViewModel.isTxDetailsUnavailable.collectAsStateWithLifecycle() var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { @@ -166,6 +168,8 @@ fun ActivityExploreScreen( item = item, isHardware = uiState.isHardwareActivity, txDetails = txDetails, + isTxDetailsLoading = isTxDetailsLoading, + isTxDetailsUnavailable = isTxDetailsUnavailable, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -190,6 +194,8 @@ private fun ActivityExploreContent( item: Activity, isHardware: Boolean = false, txDetails: TransactionDetails? = null, + isTxDetailsLoading: Boolean = false, + isTxDetailsUnavailable: Boolean = false, boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, @@ -221,9 +227,10 @@ private fun ActivityExploreContent( is Activity.Onchain -> { OnchainDetails( onchain = item, - isHardware = isHardware, onCopy = onCopy, txDetails = txDetails, + isTxDetailsLoading = isTxDetailsLoading, + isTxDetailsUnavailable = isTxDetailsUnavailable, boostTxDoesExist = boostTxDoesExist, ) Spacer(modifier = Modifier.weight(1f)) @@ -284,9 +291,10 @@ private fun LightningDetails( @Composable private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, - isHardware: Boolean, onCopy: (String) -> Unit, txDetails: TransactionDetails?, + isTxDetailsLoading: Boolean, + isTxDetailsUnavailable: Boolean, boostTxDoesExist: Map = emptyMap(), ) { val txId = onchain.v1.txId @@ -327,7 +335,7 @@ private fun ColumnScope.OnchainDetails( } }, ) - } else if (!isHardware && !onchain.v1.isTransfer) { + } else if (isTxDetailsLoading && !onchain.v1.isTransfer) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier @@ -335,6 +343,8 @@ private fun ColumnScope.OnchainDetails( .size(16.dp) .align(Alignment.CenterHorizontally) ) + } else if (isTxDetailsUnavailable && !onchain.v1.isTransfer) { + BodySSB(text = stringResource(R.string.wallet__activity_details_unavailable)) } // Display boosted transaction IDs from boostTxIds 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 c274f95cc7..b2cfa5f6c9 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 @@ -30,11 +30,11 @@ import com.synonym.bitkitcore.PaymentType import to.bitkit.R 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.uiDateStyleFor +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -62,7 +62,7 @@ import to.bitkit.ui.utils.uiDateText @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (Activity) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -98,7 +98,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 } @@ -108,7 +108,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/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index a09633a4f2..54c9f9bf7b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -144,6 +144,7 @@ class ChannelDetailViewModel @Inject constructor( private fun fetchActivityTimestamp(channelId: String) = viewModelScope.launch { val activities = activityRepo.getActivities( + walletId = null, filter = ActivityFilter.ONCHAIN, txType = PaymentType.SENT, ).getOrNull().orEmpty() diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..fe31b57c88 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,12 @@ class ActivityDetailViewModel @Inject constructor( private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() + private val _isTxDetailsLoading = MutableStateFlow(false) + val isTxDetailsLoading = _isTxDetailsLoading.asStateFlow() + + private val _isTxDetailsUnavailable = MutableStateFlow(false) + val isTxDetailsUnavailable = _isTxDetailsUnavailable.asStateFlow() + private val _tags = MutableStateFlow>(persistentListOf()) val tags = _tags.asStateFlow() @@ -53,35 +64,40 @@ class ActivityDetailViewModel @Inject constructor( val boostSheetVisible = _boostSheetVisible.asStateFlow() private var activity: Activity? = null + private var requestedWalletId: String? = null private var observeJob: Job? = null + private val currentWalletId: String + get() = activity?.walletId() ?: requestedWalletId ?: WalletScope.default private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { + requestedWalletId = walletId + activity = null + val resolvedWalletId = currentWalletId 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'", it, context = TAG) + loadHwWalletActivity(activityId, resolvedWalletId, it) } } } @@ -91,74 +107,73 @@ class ActivityDetailViewModel @Inject constructor( observeJob = null _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } activity = null + requestedWalletId = 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, - ) - } + combine(activityRepo.activitiesChanged, hwWalletRepo.activities) { _, _ -> Unit }.collect { + reloadActivity(activityId, walletId) } } } - private fun observeActivityChanges(activityId: String) { - observeJob?.cancel() - observeJob = viewModelScope.launch(bgDispatcher) { - activityRepo.activitiesChanged.collect { - reloadActivity(activityId) - } - } - } - - 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 +185,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 +199,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 +214,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,20 +229,34 @@ class ActivityDetailViewModel @Inject constructor( } fun fetchTransactionDetails(txid: String) { + if (activity == null) { + _txDetails.update { null } + _isTxDetailsLoading.update { false } + _isTxDetailsUnavailable.update { true } + return + } + val walletId = currentWalletId viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) + _isTxDetailsLoading.update { true } + _isTxDetailsUnavailable.update { false } + activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } + _isTxDetailsUnavailable.update { transactionDetails == null } } .onFailure { e -> Logger.error("fetchTransactionDetails error", e, context = TAG) _txDetails.update { null } + _isTxDetailsUnavailable.update { true } } + _isTxDetailsLoading.update { false } } } fun clearTransactionDetails() { _txDetails.update { null } + _isTxDetailsLoading.update { false } + _isTxDetailsUnavailable.update { false } } fun onClickBoost() { @@ -236,10 +268,10 @@ class ActivityDetailViewModel @Inject constructor( } suspend fun getBoostTxDoesExist(boostTxIds: List): ImmutableMap = - activityRepo.getBoostTxDoesExist(boostTxIds).toImmutableMap() + activityRepo.getBoostTxDoesExist(boostTxIds, currentWalletId).toImmutableMap() suspend fun isCpfpChildTransaction(txId: String): Boolean { - return activityRepo.isCpfpChildTransaction(txId) + return activityRepo.isCpfpChildTransaction(txId, currentWalletId) } suspend fun findOrderForTransfer( @@ -284,6 +316,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..1e2973998a 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.excludingPersisted(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.excludingPersisted(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.excludingPersisted(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 b02b43eab3..1c8c587538 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(), ), ) @@ -2525,9 +2527,14 @@ class AppViewModel @Inject constructor( } fun onClickActivityDetail() { - _transactionSheet.value.activityId?.let { + val details = _transactionSheet.value + details.activityId?.let { hideNewTransactionSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it))) + mainScreenEffect( + MainScreenEffect.Navigate( + Routes.ActivityDetail(it, details.activityWalletId) + ) + ) return } @@ -2544,7 +2551,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) @@ -2568,7 +2575,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 dcac0d1ee2..e08c0d3e9d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -57,6 +57,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 @@ -469,6 +470,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( @@ -485,6 +487,7 @@ class TransferViewModel @Inject constructor( txId = txId, fee = fee, feeRate = feeRate, + walletId = activityWalletId, ) } viewModelScope.launch { walletRepo.syncBalances() } @@ -819,6 +822,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 -> @@ -829,6 +836,7 @@ class TransferViewModel @Inject constructor( createTransferActivity = true, fee = result.miningFeeSats, feeRate = result.feeRate, + activityWalletId = walletId, ) }.onSuccess { pendingHwFundingBroadcast = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f890a6d9d..a40fe73576 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1050,6 +1050,7 @@ Contact Date Detach + Transaction details unavailable. Failed to load activity Activity not found Explore diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index ed4671630d..414c572fee 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -2,9 +2,13 @@ package to.bitkit.data import org.junit.Test import to.bitkit.data.serializers.AppCacheSerializer +import to.bitkit.ext.scopedActivityId import to.bitkit.models.BalanceState +import to.bitkit.models.WalletScope import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class AppCacheDataTest : BaseUnitTest() { companion object { @@ -47,4 +51,22 @@ class AppCacheDataTest : BaseUnitTest() { assertEquals(cache.balance, invalidated.balance) assertEquals(cache.paidOrders, invalidated.paidOrders) } + + @Test + fun `legacy deleted activity id applies only to the default wallet`() = test { + val cache = AppCacheData(deletedActivities = listOf("shared-id")) + + assertTrue(cache.isActivityDeleted("shared-id", WalletScope.default)) + assertFalse(cache.isActivityDeleted("shared-id", "hardware-wallet")) + } + + @Test + fun `scoped deleted activity id applies only to its wallet`() = test { + val cache = AppCacheData( + deletedActivities = listOf(scopedActivityId("hardware-wallet", "shared-id")) + ) + + assertTrue(cache.isActivityDeleted("shared-id", "hardware-wallet")) + assertFalse(cache.isActivityDeleted("shared-id", WalletScope.default)) + } } diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index f21e15ea02..915f35d3bf 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -11,6 +11,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -80,4 +81,14 @@ class CacheStoreTest : BaseUnitTest() { assertFalse(invalidated) assertEquals(replacement, sut.data.first()) } + + @Test + fun `addActivityToDeletedList persists a wallet scoped id`() = test { + sut.addActivityToDeletedList("shared-id", "hardware-wallet") + + assertEquals( + listOf(scopedActivityId("hardware-wallet", "shared-id")), + sut.data.first().deletedActivities, + ) + } } 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..af7a08cdbc 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, @@ -117,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 @@ -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 @@ -273,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) @@ -294,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)) } @@ -311,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 caf7b134f4..7af4df3e8d 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,11 +17,13 @@ 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 import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -40,6 +43,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 +71,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 +83,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 +98,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 +267,76 @@ 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.isTxDetailsLoading.value) + assertFalse(sut.isTxDetailsUnavailable.value) + } + + @Test + fun `transaction details failure reaches unavailable state`() = test { + val activity = createTestActivity(ACTIVITY_ID, walletId = HARDWARE_WALLET_ID) + 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.getTransactionDetails(activity.v1.txId, HARDWARE_WALLET_ID) + }.thenReturn(Result.failure(AppError("details unavailable"))) + + sut.loadActivity(ACTIVITY_ID, HARDWARE_WALLET_ID) + sut.fetchTransactionDetails(activity.v1.txId) + + assertNull(sut.txDetails.value) + assertFalse(sut.isTxDetailsLoading.value) + assertTrue(sut.isTxDetailsUnavailable.value) + } + + @Test + fun `transaction details request without activity reaches unavailable state`() = test { + sut.fetchTransactionDetails("missing") + + assertNull(sut.txDetails.value) + assertFalse(sut.isTxDetailsLoading.value) + assertTrue(sut.isTxDetailsUnavailable.value) + + sut.clearTransactionDetails() + + assertFalse(sut.isTxDetailsUnavailable.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..0fcd4e6112 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -2,11 +2,13 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.IcJitEntry 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 @@ -15,7 +17,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,9 +30,11 @@ 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 +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -57,13 +60,15 @@ class ActivityRepoTest : BaseUnitTest() { private val testActivityV1 = mock { on { id } doReturn "activity1" + on { walletId } doReturn "wallet0" } private val testActivity = mock { 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 +164,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 +203,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = eq(WalletScope.default), filter = any(), txType = any(), tags = any(), @@ -225,6 +233,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 +263,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 +274,53 @@ 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, - ) - whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + fun `getActivity rethrows cancellation`() = test { + val cancellation = CancellationException("cancelled") + whenever(coreService.activity.getActivity("activity123", WalletScope.default)).thenThrow(cancellation) - val result = sut.syncHardwareOnchainActivity(watcher) + val thrown = assertFailsWith { + sut.getActivity("activity123") + } - 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, + 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.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + whenever( + coreService.activity.replaceHwSnapshot( + walletId = walletId, + activities = listOf(activity), + transactionDetails = emptyList(), + ) + ).thenReturn(listOf(activity)) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList()) + + assertEquals(listOf(activity), result.getOrThrow()) + verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList()) + } + + @Test + fun `deleteForWallet removes scoped Core activity`() = test { + whenever(coreService.activity.deleteByWalletId("hardware-wallet")).thenReturn(2u) + + 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 +344,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 +378,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 +466,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,37 +486,37 @@ 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()) + verify(cacheStore, never()).addActivityToDeletedList(any(), any()) } @Test fun `deleteActivity deletes successfully`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.delete(activityId) }.thenReturn(true) - wheneverBlocking { cacheStore.addActivityToDeletedList(activityId) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.delete(activityId, WalletScope.default) }.thenReturn(true) + whenever { cacheStore.addActivityToDeletedList(activityId, WalletScope.default) }.thenReturn(Unit) val result = sut.deleteActivity(activityId) assertTrue(result.isSuccess) - verify(coreService.activity).delete(activityId) - verify(cacheStore).addActivityToDeletedList(activityId) + verify(coreService.activity).delete(activityId, WalletScope.default) + verify(cacheStore).addActivityToDeletedList(activityId, WalletScope.default) } @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) assertTrue(result.isFailure) - verify(cacheStore, never()).addActivityToDeletedList(any()) + verify(cacheStore, never()).addActivityToDeletedList(any(), any()) } @Test @@ -548,11 +542,25 @@ class ActivityRepoTest : BaseUnitTest() { verify(coreService.activity, never()).insert(any()) } + @Test + fun `insertActivity allows hardware activity matching a deleted default id`() = test { + val hardwareActivity = createOnchainActivity(id = "activity1").let { + Activity.Onchain(it.v1.copy(walletId = "hardware-wallet")) + } + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(deletedActivities = listOf("activity1")))) + whenever { coreService.activity.insert(hardwareActivity) }.thenReturn(Unit) + + val result = sut.insertActivity(hardwareActivity) + + assertTrue(result.isSuccess) + verify(coreService.activity).insert(hardwareActivity) + } + @Test 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 +574,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 +600,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 +634,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 +674,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 +701,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) @@ -703,6 +720,18 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(allTags, result.getOrThrow()) } + @Test + fun `getAllActivitiesTags returns only default wallet tags`() = test { + val defaultTags = ActivityTags(WalletScope.default, "default-activity", listOf("daily")) + val hardwareTags = ActivityTags("hardware-wallet", "hardware-activity", listOf("cold")) + whenever { coreService.activity.getAllActivitiesTags() } + .thenReturn(listOf(defaultTags, hardwareTags)) + + val result = sut.getAllActivitiesTags() + + assertEquals(listOf(defaultTags), result.getOrThrow()) + } + @Test fun `removeAllActivities removes all activities successfully`() = test { wheneverBlocking { coreService.activity.removeAll() }.thenReturn(Unit) @@ -771,6 +800,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 +853,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 +906,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 +958,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 +971,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 +989,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 +1017,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..b92bfa2cd0 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 @@ -39,26 +39,27 @@ import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.WalletScope import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@OptIn(ExperimentalCoroutinesApi::class) @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() private val settingsStore = mock() - private val clock = mock() private lateinit var storeData: MutableStateFlow private lateinit var settingsData: MutableStateFlow @@ -74,6 +75,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = HARDWARE_WALLET_ID, ) @Before @@ -90,10 +92,17 @@ class HwWalletRepoTest : BaseUnitTest() { val xpubs = invocation.getArgument>(0) "derived-${xpubs.values.sorted().joinToString()}" } - runBlocking { - whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) + whenever { + activityRepo.persistHwSnapshot( + any(), + any>(), + any>(), + ) + }.thenAnswer { + it.getArgument>(1) } - whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) + whenever { activityRepo.getWalletIds() }.thenReturn(Result.success(emptySet())) + whenever { activityRepo.deleteForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -101,7 +110,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -166,11 +174,81 @@ 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).persistHwSnapshot( + walletId = HARDWARE_WALLET_ID, + activities = wallet.activities, + transactionDetails = emptyList(), + ) } @Test - fun `balances from multiple address-type watchers are summed per device`() = test { + fun `unchanged watcher snapshot reuses the persisted activity`() = test { + val sourceActivity = watcherActivity(amount = 100uL) + val persistedActivity = Activity.Onchain( + sourceActivity.v1.copy(isTransfer = true), + ) + val event = transactionsChanged( + total = 100uL, + activities = listOf(sourceActivity), + ) + whenever { + activityRepo.persistHwSnapshot( + HARDWARE_WALLET_ID, + event.activities, + event.transactionDetails, + ) + }.thenReturn(Result.success(listOf(persistedActivity))) + val sut = createRepo() + + watcherEvents.emit("dev1|nativeSegwit" to event) + watcherEvents.emit("dev1|nativeSegwit" to event) + + assertTrue((sut.activities.value.single() as Activity.Onchain).v1.isTransfer) + verify(activityRepo).persistHwSnapshot( + walletId = HARDWARE_WALLET_ID, + activities = event.activities, + transactionDetails = event.transactionDetails, + ) + } + + @Test + fun `pending timestamp changes reuse snapshot until confirmation`() = test { + val pendingActivity = watcherActivity( + amount = 100uL, + blockHeight = null, + timestamp = 1_700_000_000uL, + confirmations = 0u, + ) + val pending = transactionsChanged( + total = 100uL, + activities = listOf(pendingActivity), + ) + val refreshedPending = pending.copy( + activities = listOf( + Activity.Onchain(pendingActivity.v1.copy(timestamp = 1_700_000_001uL)) + ), + ) + val confirmed = refreshedPending.copy( + activities = listOf( + Activity.Onchain(pendingActivity.v1.copy(timestamp = 1_700_000_001uL, confirmed = true)) + ), + ) + val sut = createRepo() + + watcherEvents.emit("dev1|nativeSegwit" to pending) + watcherEvents.emit("dev1|nativeSegwit" to refreshedPending) + watcherEvents.emit("dev1|nativeSegwit" to confirmed) + + assertTrue((sut.activities.value.single() as Activity.Onchain).v1.confirmed) + verify(activityRepo, times(2)).persistHwSnapshot( + eq(HARDWARE_WALLET_ID), + any>(), + any>(), + ) + } + + @Test + fun `events from inactive address-type watchers are ignored`() = test { val sut = createRepo() watcherEvents.emit( @@ -193,9 +271,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 +301,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 +327,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 +358,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 +368,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 +407,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 +595,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 +608,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() assertEquals(0, received.size) // New inbound tx after the baseline emits once. @@ -540,7 +625,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)).persistHwSnapshot( + 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 +651,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.persistHwSnapshot(HARDWARE_WALLET_ID, baseline, emptyList()) + }.thenReturn(Result.success(baseline)) + whenever { + activityRepo.persistHwSnapshot(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 +724,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), @@ -585,6 +734,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.TAPROOT, ) ) + runCurrent() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -596,6 +746,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), @@ -606,8 +757,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() } @@ -738,6 +893,35 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(0uL, sut.totalSats.value) } + @Test + fun `store removal deletes the hardware wallet activity scope`() = test { + wheneverStartWatcher().thenReturn(Result.success(Unit)) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + createRepo() + runCurrent() + + storeData.value = HwWalletData(knownDevices = emptyList()) + runCurrent() + + verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) + } + + @Test + fun `startup deletes persisted activity scopes without a known device`() = test { + whenever { activityRepo.getWalletIds() }.thenReturn( + Result.success(setOf(WalletScope.default, HARDWARE_WALLET_ID, "orphan-wallet")) + ) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + + createRepo() + runCurrent() + + verify(activityRepo).deleteForWallet("orphan-wallet") + verify(activityRepo, never()).deleteForWallet(WalletScope.default) + verify(activityRepo, never()).deleteForWallet(HARDWARE_WALLET_ID) + } + @Test fun `resetState clears store and stops active watchers`() = test { storeData.value = HwWalletData( @@ -777,6 +961,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 +992,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 +1297,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 +1318,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..ce87680cdf 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -29,9 +29,11 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.models.TransferType +import to.bitkit.models.WalletScope 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 +187,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 +318,8 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create(walletId = "wallet0", + val activity = OnchainActivity.create( + walletId = "hardware-wallet", id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -307,7 +335,12 @@ 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.getWalletIds()) + .thenReturn(setOf(WalletScope.default, "hardware-wallet")) + whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid, WalletScope.default)) + .thenReturn(null) + whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid, "hardware-wallet")) + .thenReturn(activity) val result = sut.syncTransferStates() @@ -609,7 +642,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 +670,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 +776,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 +809,9 @@ 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.getWalletIds()).thenReturn(setOf(WalletScope.default)) + whenever(activityService.getOnchainActivityByTxId(sweepTxid, WalletScope.default)) + .thenReturn(sweepActivity) whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) val result = sut.syncTransferStates() diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt new file mode 100644 index 0000000000..82e124cb6c --- /dev/null +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -0,0 +1,70 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import org.junit.Test +import to.bitkit.ext.create +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoreServiceTest { + + @Test + fun `merge hw snapshot keeps one transfer row for the same transaction`() { + val existing = activity( + id = "transfer", + isTransfer = true, + channelId = "channel", + transferTxId = "funding", + ) + val incoming = activity(id = "transfer") + + val result = mergeHwSnapshot(existing = listOf(existing), incoming = listOf(incoming)) + + assertTrue(result.toDelete.isEmpty()) + assertEquals(1, result.toUpsert.size) + val merged = result.toUpsert.single() as Activity.Onchain + assertTrue(merged.v1.isTransfer) + assertEquals("channel", merged.v1.channelId) + assertEquals("funding", merged.v1.transferTxId) + } + + @Test + fun `merge hw snapshot deletes only stale non-transfer rows`() { + val stale = activity(id = "stale") + val transfer = activity(id = "transfer", isTransfer = true) + val incoming = activity(id = "current") + + val result = mergeHwSnapshot( + existing = listOf(stale, transfer), + incoming = listOf(incoming), + ) + + assertEquals(listOf(stale), result.toDelete) + assertEquals(listOf(incoming), result.toUpsert) + assertFalse(result.toDelete.single().v1.isTransfer) + } + + private fun activity( + id: String, + isTransfer: Boolean = false, + channelId: String? = null, + transferTxId: String? = null, + ) = Activity.Onchain( + OnchainActivity.create( + walletId = "hardware-wallet", + id = id, + txType = PaymentType.RECEIVED, + txId = id, + value = 1uL, + fee = 0uL, + address = "", + timestamp = 1uL, + isTransfer = isTransfer, + channelId = channelId, + transferTxId = transferTxId, + ) + ) +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt index 3134f5d484..d0b94d946b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt @@ -66,7 +66,9 @@ class HomeViewModelTest : BaseUnitTest() { whenever(pubkyRepo.displayName).thenReturn(MutableStateFlow(null)) whenever(pubkyRepo.displayImageUri).thenReturn(MutableStateFlow(null)) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) - whenever { activityRepo.getActivities(limit = 1u) }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.getActivities(walletId = null, limit = 1u) + }.thenReturn(Result.success(emptyList())) whenever(hwWalletRepo.wallets).thenReturn(hardwareWallets) whenever(suggestionsRepo.suggestionsFlow).thenReturn(suggestions) } diff --git a/app/src/test/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModelTest.kt new file mode 100644 index 0000000000..90b9782a9a --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModelTest.kt @@ -0,0 +1,102 @@ +package to.bitkit.ui.settings.lightning + +import android.content.Context +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.SortDirection +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.ext.create +import to.bitkit.ext.createChannelDetails +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ChannelDetailViewModelTest : BaseUnitTest() { + + private companion object { + const val CHANNEL_ID = "channel-id" + const val TIMESTAMP = 1_700_000_000uL + } + + private val context = mock() + private val lightningRepo = mock() + private val blocktankRepo = mock() + private val activityRepo = mock() + + @Before + fun setUp() { + whenever(context.getString(R.string.lightning__connection)).thenReturn("Connection") + whenever(lightningRepo.lightningState).thenReturn( + MutableStateFlow( + LightningState( + channels = persistentListOf( + createChannelDetails().copy(channelId = CHANNEL_ID), + ) + ) + ) + ) + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) + whenever { + activityRepo.getClosedChannels(SortDirection.DESC) + }.thenReturn(Result.success(emptyList())) + } + + @Test + fun `loads opening timestamp from hardware wallet transfer`() = test { + val transferActivity = Activity.Onchain( + OnchainActivity.create( + walletId = "hardware-wallet", + id = "funding-txid", + txType = PaymentType.SENT, + txId = "funding-txid", + value = 100uL, + fee = 1uL, + address = "bc1qtest", + timestamp = TIMESTAMP, + isTransfer = true, + channelId = CHANNEL_ID, + ) + ) + whenever { + activityRepo.getActivities( + walletId = null, + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + ) + }.thenReturn(Result.success(listOf(transferActivity))) + val sut = createViewModel() + + sut.loadChannel(CHANNEL_ID) + advanceUntilIdle() + + assertEquals(TIMESTAMP, sut.uiState.value.txTime) + verify(activityRepo).getActivities( + walletId = null, + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + ) + } + + private fun createViewModel() = ChannelDetailViewModel( + context = context, + lightningRepo = lightningRepo, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + ) +} 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 5248ef48bf..cd9976eac3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -344,13 +344,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 0b70bb3a2b..7b6c796b14 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -116,6 +116,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))) @@ -604,6 +605,7 @@ class TransferViewModelTest : BaseUnitTest() { eq(TXID), eq(MINING_FEE), eq(FEE_RATE), + eq(HARDWARE_WALLET_ID), ) verify(hwWalletRepo).ensureConnected(DEVICE_ID) } @@ -1152,7 +1154,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 @@ -1574,6 +1582,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..7c42c552ac --- /dev/null +++ b/changelog.d/next/1044.changed.md @@ -0,0 +1 @@ +Added support for tags and transaction details to hardware wallet activity. 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" +