diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..fba9c05e0c 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,12 +5,29 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import to.bitkit.models.ActivityWalletType + +/** + * Wallet id of the local Bitkit wallet. Mirrors Bitkit Core's `getDefaultWalletId()` (Rust + * `DEFAULT_WALLET_ID`); kept local so the value is available without a JNI call. Hardware wallets + * use their own derived id instead. + */ +val DEFAULT_WALLET_ID: String get() = ActivityWalletType.BITKIT.id fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id is Activity.Onchain -> v1.id } +fun Activity.walletId(): String = when (this) { + is Activity.Lightning -> v1.walletId + is Activity.Onchain -> v1.walletId +} + +fun Activity.scopedId(): String = "${walletId()}:${rawId()}" + +fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) + fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType @@ -107,7 +124,9 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = LightningActivity( + walletId = walletId, id = id, txType = txType, status = status, @@ -145,7 +164,9 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = OnchainActivity( + walletId = walletId, id = id, txType = txType, txId = txId, diff --git a/app/src/main/java/to/bitkit/models/ActivityWalletType.kt b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt new file mode 100644 index 0000000000..8293f048ea --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,29 @@ +package to.bitkit.models + +import java.security.MessageDigest +import java.util.Locale + +enum class ActivityWalletType { + BITKIT, + TREZOR, + ; + + val id: String + get() = name.lowercase(Locale.US) + + fun owns(walletId: String): Boolean = walletId == id || walletId.startsWith(prefix) + + fun scopedId(value: String): String = "$prefix$value" + + fun deriveId(keys: Collection): String { + val normalizedKeys = keys.filter { it.isNotBlank() } + if (normalizedKeys.isEmpty()) return "" + + val hash = MessageDigest.getInstance("SHA-256") + .digest(normalizedKeys.sorted().joinToString("\n").toByteArray(Charsets.UTF_8)) + return scopedId(hash.joinToString("") { "%02x".format(it) }) + } + + private val prefix: String + get() = "$id:" +} diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a01..2c7f3eb48b 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/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 7dfb8f4d10..1c340df6b5 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -34,6 +34,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountOnClose import to.bitkit.ext.contact import to.bitkit.ext.isReplacedSentTransaction @@ -213,23 +214,31 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + /** + * Persists the wallet-scoped activities and transaction details a hardware-wallet watcher + * emits, so hardware transactions become first-class Bitkit Core activities (tags, inputs/outputs). + */ + suspend fun persistHardwareActivities( + activities: List, + transactionDetails: List, + ): 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), - ) - if (updated == existing) return@runCatching - coreService.activity.update(existing.id, Activity.Onchain(updated)) + if (activities.isNotEmpty()) coreService.activity.upsertList(activities) + if (transactionDetails.isNotEmpty()) coreService.activity.upsertTransactionDetailsList(transactionDetails) + if (activities.isNotEmpty() || transactionDetails.isNotEmpty()) notifyActivitiesChanged() + }.onFailure { + Logger.error("Failed to persist hardware activities", it, context = TAG) + } + } + + /** Removes all activity, details and tag metadata scoped to a hardware wallet's id. */ + suspend fun deleteActivitiesForWallet(walletId: String): Result = withContext(bgDispatcher) { + runCatching { + val deleted = coreService.activity.deleteByWalletId(walletId) notifyActivitiesChanged() + Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG) }.onFailure { - Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG) } } @@ -257,22 +266,25 @@ 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? = null): Boolean { + return coreService.activity.isActivitySeen(activityId, walletId) } - suspend fun markActivityAsSeen(activityId: String) { - coreService.activity.markActivityAsSeen(activityId) + suspend fun markActivityAsSeen(activityId: String, walletId: String? = null) { + coreService.activity.markActivityAsSeen(activityId, walletId = walletId) notifyActivitiesChanged() } - suspend fun markOnchainActivityAsSeen(txid: String) { - coreService.activity.markOnchainActivityAsSeen(txid) + suspend fun markOnchainActivityAsSeen(txid: String, walletId: String? = null) { + 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? = null, + ): Result = runCatching { + coreService.activity.getTransactionDetails(txid, walletId) } suspend fun getBoostTxDoesExist(boostTxIds: List): Map { @@ -327,6 +339,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -337,7 +350,7 @@ class ActivityRepo @Inject constructor( sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + coreService.activity.get(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) }.onFailure { Logger.error( "getActivities error. Parameters:" + @@ -355,11 +368,11 @@ class ActivityRepo @Inject constructor( } } - suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { + suspend fun getActivity(id: String, walletId: String? = null): Result = withContext(bgDispatcher) { runCatching { - coreService.activity.getActivity(id) + coreService.activity.getActivity(id, walletId) }.onFailure { - Logger.error("getActivity error for ID: $id", it, context = TAG) + Logger.error("Failed to get activity '$id'", it, context = TAG) } } @@ -654,6 +667,7 @@ class ActivityRepo @Inject constructor( insertActivity( Activity.Lightning( LightningActivity( + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -684,15 +698,18 @@ class ActivityRepo @Inject constructor( suspend fun addTagsToActivity( activityId: String, tags: List, + walletId: String? = null, ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - val existingTags = coreService.activity.tags(activityId) + val existingTags = coreService.activity.tags(activityId, walletId) val newTags = tags.filter { it.isNotBlank() && it !in existingTags } if (newTags.isNotEmpty()) { - coreService.activity.appendTags(activityId, newTags).getOrThrow() + coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() notifyActivitiesChanged() Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { @@ -726,12 +743,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? = null, + ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - coreService.activity.dropTags(activityId, tags) + coreService.activity.dropTags(activityId, tags, walletId) notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { @@ -742,9 +765,12 @@ class ActivityRepo @Inject constructor( /** * Gets all tags for an activity */ - suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { + suspend fun getActivityTags( + activityId: String, + walletId: String? = null, + ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.tags(activityId) + coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f4992921c0..2043391fef 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -4,12 +4,9 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WatcherEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -37,9 +34,11 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env -import to.bitkit.ext.create import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedId +import to.bitkit.ext.walletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -58,9 +57,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 @@ -71,14 +68,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 { @@ -92,6 +87,7 @@ class HwWalletRepo @Inject constructor( private val activeWatchers = mutableSetOf() private val activeWatcherElectrumUrls = mutableMapOf() + private val activeWatcherWalletIds = mutableMapOf() private val retryingWatcherStarts = mutableSetOf() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) @@ -114,6 +110,7 @@ class HwWalletRepo @Inject constructor( } activeWatchers.clear() activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() retryingWatcherStarts.clear() emittedReceivedTxIds.clear() _watcherData.update { emptyMap() } @@ -278,6 +275,11 @@ class HwWalletRepo @Inject constructor( 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" } + + // Drop the removed wallet's hardware activity/details/tags from Bitkit Core so the + // activity database does not grow for unpaired devices; re-pairing rebuilds from the watcher. + val walletIdToPurge = target?.walletId?.takeIf { it.isNotBlank() } + if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge).getOrThrow() }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -327,21 +329,6 @@ class HwWalletRepo @Inject constructor( .map { wallets -> wallets.fold(0uL) { acc, wallet -> acc + wallet.balanceSats } } .stateIn(scope, SharingStarted.Eagerly, 0uL) - val activities: StateFlow> = combine( - hwWalletStore.data, - _watcherData, - ) { data, watcherData -> - val knownDeviceIds = data.knownDevices - .filter { it.xpubs.isNotEmpty() } - .map { it.id } - .toSet() - watcherData.values - .filter { it.deviceId in knownDeviceIds } - .toMergedActivities() - .toImmutableList() - } - .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) - init { observeWatcherEvents() syncWatchers() @@ -351,23 +338,24 @@ class HwWalletRepo @Inject constructor( scope.launch { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect + val walletId = activeWatcherWalletIds[watcherId] ?: return@collect + val activities = event.activities.filter { it.walletId() == walletId } + val transactionDetails = event.transactionDetails.filter { it.walletId == walletId } + + activityRepo.persistHardwareActivities(activities, transactionDetails).getOrElse { + return@collect + } + val previous = _watcherData.value[watcherId] - val activities = event.transactions - .map { it.toOnchainActivity(clock, previous?.activities.orEmpty()) } - .toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - transactions = event.transactions.toImmutableList(), - activities = activities, + activities = activities.toImmutableList(), ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } - activities.filterIsInstance().forEach { - activityRepo.syncHardwareOnchainActivity(it.v1) - } - emitReceivedTxs(previous, event, updatedWatcherData) + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -378,21 +366,22 @@ class HwWalletRepo @Inject constructor( */ private suspend fun emitReceivedTxs( previous: HwWatcherData?, - event: WatcherEvent.TransactionsChanged, + activities: List, watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.map { it.rawId() }.toSet() + val knownActivityIds = previous.activities.map { it.scopedId() }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - event.transactions + activities + .filterIsInstance() .filter { - it.direction == TxDirection.RECEIVED && - it.txid !in knownTxIds && - emittedReceivedTxIds.add(it.txid) + it.v1.txType == PaymentType.RECEIVED && + it.scopedId() !in knownActivityIds && + emittedReceivedTxIds.add(it.scopedId()) } .forEach { - val sats = mergedActivities.findOnchain(it.txid)?.v1?.value ?: it.amount - _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = sats)) + val sats = mergedActivities.findOnchain(it.v1.txId, it.v1.walletId)?.v1?.value ?: it.v1.value + _receivedTxs.emit(HwWalletReceivedTx(txid = it.v1.txId, sats = sats, walletId = it.v1.walletId)) } } @@ -413,53 +402,68 @@ class HwWalletRepo @Inject constructor( ) { desired, _ -> desired }.collect { (knownDevices, watcherSettings) -> - // Only watch the address types the user monitors (Settings > Advanced > Address Type), - // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so - // toggling a type on later starts its watcher without reconnecting the device. - // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. - val filtered = knownDevices.flatMap { device -> - device.xpubs - .filterKeys { it in watcherSettings.monitoredTypes } - .map { (addressType, xpub) -> - WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl) - } - }.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) 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, - ).onSuccess { - activeWatchers += spec.watcherId - activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl - retryingWatcherStarts -= spec.watcherId - }.onFailure { - Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) - scheduleWatcherStartRetry(spec.watcherId) - } - } + val filtered = knownDevices.toWatcherSpecs(watcherSettings) + filtered.forEach { syncWatcher(it) } // 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 -> + (activeWatchers - filtered.map { it.watcherId }.toSet()).forEach { staleId -> stopActiveWatcher(staleId) } } } } + private fun List.toWatcherSpecs(watcherSettings: WatcherSettings): List = + flatMap { device -> + val walletId = device.walletId.takeIf { it.isNotBlank() } + ?: ActivityWalletType.TREZOR.deriveId(device.xpubs.values) + if (walletId.isBlank()) return@flatMap emptyList() + + device.xpubs + .filterKeys { it in watcherSettings.monitoredTypes } + .map { (addressType, xpub) -> + WatcherSpec(device.id, walletId, addressType, xpub, watcherSettings.electrumUrl) + } + }.distinctBy { it.addressType to it.xpub } + + private suspend fun syncWatcher(spec: WatcherSpec) { + val isActive = spec.watcherId in activeWatchers + if ( + isActive && + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && + activeWatcherWalletIds[spec.watcherId] == spec.walletId + ) { + return + } + if (isActive && !stopActiveWatcher(spec.watcherId)) return + + activeWatchers += spec.watcherId + activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + activeWatcherWalletIds[spec.watcherId] = spec.walletId + trezorRepo.startWatcher( + watcherId = spec.watcherId, + walletId = spec.walletId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + electrumUrl = spec.electrumUrl, + ).onSuccess { + retryingWatcherStarts -= spec.watcherId + }.onFailure { + activeWatchers -= spec.watcherId + activeWatcherElectrumUrls -= spec.watcherId + activeWatcherWalletIds -= spec.watcherId + Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) + scheduleWatcherStartRetry(spec.watcherId) + } + } + private suspend fun stopActiveWatcher(watcherId: String): Boolean = trezorRepo.stopWatcher(watcherId).onSuccess { activeWatchers -= watcherId activeWatcherElectrumUrls -= watcherId + activeWatcherWalletIds -= watcherId _watcherData.update { it - watcherId } }.isSuccess @@ -473,66 +477,64 @@ class HwWalletRepo @Inject constructor( } } - private fun HistoryTransaction.toOnchainActivity(clock: Clock, previousActivities: List): Activity { - val activityTimestamp = timestamp ?: previousActivities.findOnchain(txid)?.v1?.timestamp - ?: clock.now().epochSeconds.toULong() - return listOf(this).toOnchainActivity( - timestamp = activityTimestamp, - sourceActivities = previousActivities, - ) - } - - private fun List.toMergedActivities(): List { - val sourceActivities = flatMap { it.activities } - return flatMap { it.transactions } - .groupBy { it.txid } + // The watcher emits one row per address-type account. Merge rows for the same logical transaction + // so wallet tiles and receive sheets show the wallet-level net amount instead of whichever row won. + private fun List.toMergedActivities(): List = + flatMap { it.activities } + .groupBy { it.rawId() } .values - .map { transactions -> - val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull() - ?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp - ?: 0uL - transactions.toOnchainActivity(timestamp, sourceActivities) - } - } + .map { it.mergedActivity() } - private fun List.toOnchainActivity( - timestamp: ULong, - sourceActivities: List, - ): Activity { - val first = first() - val received = fold(0uL) { acc, tx -> acc.safe() + tx.received.safe() } - val sent = fold(0uL) { acc, tx -> acc.safe() + tx.sent.safe() } - val fee = mapNotNull { it.fee }.maxOrNull() ?: 0uL - val type = when { + private fun List.mergedActivity(): Activity { + if (size == 1) return first() + + val onchainActivities = filterIsInstance() + if (onchainActivities.size != size) return first() + + val base = onchainActivities.minBy { it.v1.timestamp } + val received = onchainActivities.filter { it.v1.txType == PaymentType.RECEIVED } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val sent = onchainActivities.filter { it.v1.txType == PaymentType.SENT } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val fee = onchainActivities.maxOf { it.v1.fee } + val txType = when { received > sent -> PaymentType.RECEIVED - else -> PaymentType.SENT + sent > received -> PaymentType.SENT + else -> base.v1.txType } - val value = when (type) { + val value = when (txType) { PaymentType.RECEIVED -> received.safe() - sent.safe() PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() } - val confirmations = maxOf { it.confirmations } - val sourceActivity = sourceActivities.findOnchain(first.txid) + return Activity.Onchain( - OnchainActivity.create( - id = first.txid, - txType = type, - txId = first.txid, + base.v1.copy( + txType = txType, value = value, fee = fee, - address = "", - timestamp = timestamp, - confirmed = confirmations > 0u, - confirmTimestamp = sourceActivity?.v1?.confirmTimestamp, + address = onchainActivities.firstOrNull { it.v1.address.isNotBlank() }?.v1?.address.orEmpty(), + confirmed = onchainActivities.any { it.v1.confirmed }, + isBoosted = onchainActivities.any { it.v1.isBoosted }, + boostTxIds = onchainActivities.flatMap { it.v1.boostTxIds }.distinct(), + isTransfer = onchainActivities.any { it.v1.isTransfer }, + doesExist = onchainActivities.any { it.v1.doesExist }, + confirmTimestamp = onchainActivities.mapNotNull { it.v1.confirmTimestamp }.maxOrNull(), + channelId = onchainActivities.firstNotNullOfOrNull { it.v1.channelId }, + transferTxId = onchainActivities.firstNotNullOfOrNull { it.v1.transferTxId }, + contact = onchainActivities.firstNotNullOfOrNull { it.v1.contact }, + createdAt = onchainActivities.mapNotNull { it.v1.createdAt }.minOrNull(), + updatedAt = onchainActivities.mapNotNull { it.v1.updatedAt }.maxOrNull(), + seenAt = onchainActivities.mapNotNull { it.v1.seenAt }.minOrNull(), ) ) } - 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, + val walletId: String, val addressType: String, val xpub: String, val electrumUrl: String, @@ -577,6 +579,5 @@ private data class HwWatcherData( val deviceId: String, val addressType: String, val balanceSats: ULong, - val transactions: ImmutableList, val activities: ImmutableList, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dc4f99a7e4..4081ea14c5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -60,6 +60,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp @@ -1172,6 +1173,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index d0fa79b790..e8cbb994c0 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.services.CoreService @@ -132,6 +133,7 @@ class PreActivityMetadataRepo @Inject constructor( require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = id, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..532521a47b 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -55,6 +55,7 @@ import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath @@ -69,7 +70,6 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -737,9 +737,10 @@ class TrezorRepo @Inject constructor( suspend fun startWatcher( watcherId: String, + walletId: String, extendedKey: String, network: BitkitCoreNetwork, - gapLimit: UInt = 20u, + gapLimit: UInt = DEFAULT_GAP_LIMIT, accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), ): Result = withContext(ioDispatcher) { @@ -747,6 +748,7 @@ class TrezorRepo @Inject constructor( awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -1088,9 +1090,10 @@ private fun walletKey(xpubs: Map, fallback: String): String = private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: newHardwareWalletId() + firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }?.let { return it } + if (xpubs.values.any { it.isNotBlank() }) return deriveHardwareWalletId(xpubs) + + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() }.orEmpty() } private fun List.withHardwareWalletIds(): List { @@ -1100,12 +1103,25 @@ private fun List.withHardwareWalletIds(): List { return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { deriveHardwareWalletId(it.xpubs) } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() +/** + * Stable, cross-platform wallet id derived from the device's account xpubs, so the same physical + * device produces the same id on every platform without a backup. Blank until xpubs are captured. + * + * Mirrors Bitkit Core's `deriveWalletId` (and iOS): sha256 of the account xpubs sorted and joined + * by "\n", lower-hex, prefixed with the device type. Implemented in pure Kotlin so the deterministic + * id is available without a JNI call and stays unit-testable on the JVM. + */ +private fun deriveHardwareWalletId(xpubs: Map): String { + return ActivityWalletType.TREZOR.deriveId(xpubs.values) +} + +/** Unused-address scan gap limit for watch-only watchers; mirrors Bitkit Core's `DEFAULT_GAP_LIMIT`. */ +private const val DEFAULT_GAP_LIMIT = 20u private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6c53356f5f..74495c89ee 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -28,6 +28,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex @@ -236,6 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = paymentId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 6333f14803..ccaf6b3f46 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -30,6 +30,7 @@ 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.deriveOnchainDescriptor import com.synonym.bitkitcore.estimateOrderFeeFull @@ -80,10 +81,15 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID 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.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress @@ -232,10 +238,14 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + /** Wallet id for the local Bitkit wallet; hardware wallets pass their own derived id. */ + private val defaultWalletId: String = DEFAULT_WALLET_ID + suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -246,15 +256,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(activityId = id) } } } + suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + deleteActivitiesByWalletId(walletId) + } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { insertActivity(activity) } @@ -267,9 +280,14 @@ class ActivityService( upsertActivities(activities) } + suspend fun upsertTransactionDetailsList(list: List) = ServiceQueue.CORE.background { + upsertTransactionDetails(list) + } + private fun mapToCoreTransactionDetails( txid: String, details: TransactionDetails, + walletId: String = defaultWalletId, ): BitkitCoreTransactionDetails { val inputs = details.inputs.map { input -> BitkitCoreTxInput( @@ -290,6 +308,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -297,16 +316,22 @@ class ActivityService( ) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String? = null, + ): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(walletId ?: defaultWalletId, txid) } - suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { - getActivityById(id) + suspend fun getActivity(id: String, walletId: String? = null): Activity? = ServiceQueue.CORE.background { + getActivityById(walletId ?: defaultWalletId, id) } - suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { - getActivityByTxId(txId = txId) + suspend fun getOnchainActivityByTxId( + txId: String, + walletId: String? = null, + ): OnchainActivity? = ServiceQueue.CORE.background { + getActivityByTxId(walletId = walletId ?: defaultWalletId, txId = txId) } suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { @@ -319,6 +344,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -328,30 +354,39 @@ class ActivityService( limit: UInt? = null, sortDirection: SortDirection? = null, ): List = ServiceQueue.CORE.background { - getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + getActivities(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { updateActivity(id, activity) } - suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(id) + suspend fun delete(id: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + deleteActivityById(walletId ?: defaultWalletId, id) } - suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { + suspend fun appendTags( + toActivityId: String, + tags: List, + walletId: String? = null, + ): Result = runSuspendCatching { ServiceQueue.CORE.background { - addTags(toActivityId, tags) + addTags(walletId ?: defaultWalletId, toActivityId, tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(fromActivityId, tags) + suspend fun dropTags( + fromActivityId: String, + tags: List, + walletId: String? = null, + ) = ServiceQueue.CORE.background { + removeTags(walletId ?: defaultWalletId, fromActivityId, tags) } - suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(forActivityId) - } + suspend fun tags(forActivityId: String, walletId: String? = null): List = + ServiceQueue.CORE.background { + getTags(walletId ?: defaultWalletId, forActivityId) + } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { getAllUniqueTags() @@ -378,26 +413,38 @@ class ActivityService( } suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.addPreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.addPreActivityMetadataTags( + walletId = defaultWalletId, + paymentId = paymentId, + tags = tags + ) } suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.removePreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.removePreActivityMetadataTags( + walletId = defaultWalletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = defaultWalletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( searchKey: String, searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { - com.synonym.bitkitcore.getPreActivityMetadata(searchKey = searchKey, searchByAddress = searchByAddress) + com.synonym.bitkitcore.getPreActivityMetadata( + walletId = defaultWalletId, + searchKey = searchKey, + searchByAddress = searchByAddress, + ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = defaultWalletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -500,7 +547,7 @@ class ActivityService( return } - val existingActivity = getActivityById(payment.id) + val existingActivity = getActivityById(defaultWalletId, payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -540,7 +587,7 @@ class ActivityService( ) } - if (getActivityById(payment.id) != null) { + if (getActivityById(defaultWalletId, payment.id) != null) { updateActivity(payment.id, Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) @@ -880,7 +927,7 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(payment.id) + var existingActivity = getActivityById(defaultWalletId, payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) @@ -1379,43 +1426,52 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: return@background false + suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + val activity = getActivityById(walletId ?: defaultWalletId, activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null } } - suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: run { - Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) + suspend fun markActivityAsSeen( + activityId: String, + walletId: String? = null, + seenAt: ULong? = null, + ) = ServiceQueue.CORE.background { + val activity = getActivityById(walletId ?: defaultWalletId, activityId) ?: run { + Logger.warn("Skipped marking activity '$activityId' as seen because it was not found", 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)) } updateActivity(activityId, updatedActivity) - Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) + 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? = null, + 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) + Logger.warn("Skipped marking onchain activity '$txid' as seen because it was not found", 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 = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -1433,11 +1489,7 @@ 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) } } } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ba2b1ae635..6fe30200e4 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -46,6 +46,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING @@ -948,12 +949,12 @@ class MigrationService @Inject constructor( val onchain = activityRepo.getOnchainActivityByTxId(activityId) if (onchain != null) { applied++ - ActivityTags(activityId = onchain.id, tags = tagList) + ActivityTags(walletId = DEFAULT_WALLET_ID, activityId = onchain.id, tags = tagList) } else { val activity = activityRepo.getActivity(activityId).getOrNull() if (activity != null) { applied++ - ActivityTags(activityId = activityId, tags = tagList) + ActivityTags(walletId = DEFAULT_WALLET_ID, activityId = activityId, tags = tagList) } else { Logger.warn("Activity not found for tags: id=$activityId", context = TAG) null @@ -1005,6 +1006,7 @@ class MigrationService @Inject constructor( Activity.Lightning( LightningActivity( + walletId = DEFAULT_WALLET_ID, id = item.id, txType = txType, status = status, @@ -1960,6 +1962,7 @@ class MigrationService @Inject constructor( val activityTimestamp = if (timestampSecs > 0u) timestampSecs else now val newOnchain = OnchainActivity( + walletId = DEFAULT_WALLET_ID, id = item.id, txType = if (item.txType == "sent") PaymentType.SENT else PaymentType.RECEIVED, txId = txId, diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 023045080d..c4233acd48 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult +import com.synonym.bitkitcore.TrezorTransportErrorCode import com.synonym.bitkitcore.TrezorTransportReadResult import com.synonym.bitkitcore.TrezorTransportWriteResult import kotlinx.serialization.Serializable @@ -97,29 +98,29 @@ class TrezorBridgeTransport( val session = json.decodeFromString(response).session openSessions[path] = session Logger.info("Opened Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") }.getOrElse { Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed") + transportWriteResult(success = false, error = it.message ?: "Bridge open failed") } } fun closeDevice(path: String): TrezorTransportWriteResult { val session = openSessions.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") return runCatching { post("/release/${encode(session)}") Logger.info("Closed Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") }.getOrElse { Logger.warn("Failed to close Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed") + transportWriteResult(success = false, error = it.message ?: "Bridge close failed") } } fun readChunk(path: String): TrezorTransportReadResult { - return TrezorTransportReadResult( + return transportReadResult( success = false, data = byteArrayOf(), error = "Trezor Bridge uses callMessage for '$path'", @@ -127,7 +128,7 @@ class TrezorBridgeTransport( } fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult { - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "Trezor Bridge uses callMessage for '$path' and ignored '${data.size}' bytes", ) @@ -139,7 +140,7 @@ class TrezorBridgeTransport( data: ByteArray, ): TrezorCallMessageResult { val session = openSessions[path] - ?: return TrezorCallMessageResult( + ?: return callMessageResult( success = false, messageType = 0u.toUShort(), data = byteArrayOf(), @@ -153,7 +154,7 @@ class TrezorBridgeTransport( decodeFrame(response) }.getOrElse { Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG) - TrezorCallMessageResult( + callMessageResult( success = false, messageType = 0u.toUShort(), data = byteArrayOf(), @@ -184,7 +185,7 @@ class TrezorBridgeTransport( "Bridge response payload length '$length' exceeds '${bytes.size - HEADER_SIZE}' bytes" } - return TrezorCallMessageResult( + return callMessageResult( success = true, messageType = messageType, data = bytes.copyOfRange(HEADER_SIZE, HEADER_SIZE + length), @@ -236,3 +237,30 @@ class TrezorBridgeTransport( val session: String, ) } + +private fun transportWriteResult( + success: Boolean, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportWriteResult(success = success, error = error, errorCode = errorCode) + +private fun transportReadResult( + success: Boolean, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportReadResult(success = success, data = data, error = error, errorCode = errorCode) + +private fun callMessageResult( + success: Boolean, + messageType: UShort, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorCallMessageResult( + success = success, + messageType = messageType, + data = data, + error = error, + errorCode = errorCode, +) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index cba4965b59..4ba662725d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -32,6 +32,7 @@ import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult import com.synonym.bitkitcore.TrezorTransportCallback +import com.synonym.bitkitcore.TrezorTransportErrorCode import com.synonym.bitkitcore.TrezorTransportReadResult import com.synonym.bitkitcore.TrezorTransportWriteResult import dagger.hilt.android.qualifiers.ApplicationContext @@ -680,18 +681,18 @@ class TrezorTransport @Inject constructor( closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return transportWriteResult(success = false, error = "Device not found: $path") if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { Logger.info("Skipped USB permission request for '$path'", context = TAG) - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "USB permission missing for '$path'", ) } if (!requestUsbPermission(device)) { - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "USB permission denied for '$path'", ) @@ -699,19 +700,19 @@ class TrezorTransport @Inject constructor( } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + ?: return transportWriteResult(success = false, error = "Failed to open device: $path") val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") + return transportWriteResult(success = false, error = "Failed to claim interface") } val endpoints = findUsbEndpoints(usbInterface) if (endpoints == null) { connection.releaseInterface(usbInterface) connection.close() - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "Could not find required endpoints", ) @@ -724,10 +725,10 @@ class TrezorTransport @Inject constructor( endpoints.write, ) Logger.info("USB device opened: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB open failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -735,15 +736,15 @@ class TrezorTransport @Inject constructor( private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") openDevice.connection.releaseInterface(openDevice.usbInterface) openDevice.connection.close() Logger.info("USB device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -751,7 +752,7 @@ class TrezorTransport @Inject constructor( private fun readUsbChunk(path: String): TrezorTransportReadResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Device not open: $path", @@ -769,7 +770,7 @@ class TrezorTransport @Inject constructor( READ_TIMEOUT_MS, ) if (bytesRead < 0) { - return TrezorTransportReadResult( + return transportReadResult( success = false, data = byteArrayOf(), error = "USB read timed out", @@ -777,10 +778,10 @@ class TrezorTransport @Inject constructor( } Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") + transportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") + transportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") } } @@ -788,7 +789,7 @@ class TrezorTransport @Inject constructor( private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return transportWriteResult(success = false, error = "Device not open: $path") val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -797,14 +798,14 @@ class TrezorTransport @Inject constructor( WRITE_TIMEOUT_MS, ) if (bytesWritten != data.size) { - return TrezorTransportWriteResult(success = false, error = "USB write timed out") + return transportWriteResult(success = false, error = "USB write timed out") } Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -870,18 +871,18 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + return transportWriteResult(success = false, error = "Failed to initiate bonding") } var bondAttempts = 0 while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") + return transportWriteResult(success = false, error = "Bonding failed or rejected") } } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + return transportWriteResult(success = false, error = "Bonding timeout") } Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { @@ -892,7 +893,7 @@ class TrezorTransport @Inject constructor( bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed") + return transportWriteResult(success = false, error = "Bonding failed") } } else { Logger.info("Device already bonded: '$address'", context = TAG) @@ -910,7 +911,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") } Logger.info("Reused open BLE device '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } val address = path.removePrefix("ble:") @@ -919,7 +920,7 @@ class TrezorTransport @Inject constructor( // fresh scan — a scan right after a disconnect often finds nothing yet. val device = discoveredBleDevices[address] ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return transportWriteResult(success = false, error = "Device not found: $path") bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } @@ -940,13 +941,13 @@ class TrezorTransport @Inject constructor( if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Connection timeout") + return transportWriteResult(success = false, error = "Connection timeout") } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Failed to connect") + return transportWriteResult(success = false, error = "Failed to connect") } gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) @@ -961,27 +962,27 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) Logger.info("BLE device opened: '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") connection.readQueue.clear() connection.writeLatch?.countDown() connection.connectionLatch?.countDown() Logger.info("Closed BLE device session '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") userInitiatedCloseSet.add(path) return try { @@ -1000,10 +1001,10 @@ class TrezorTransport @Inject constructor( connection.gatt.close() Thread.sleep(100) Logger.info("BLE device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) + transportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) } catch (e: Exception) { Logger.error("BLE close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed") + transportWriteResult(success = false, error = e.message ?: "BLE close failed") } finally { userInitiatedCloseSet.remove(path) } @@ -1012,7 +1013,7 @@ class TrezorTransport @Inject constructor( @Suppress("TooGenericExceptionCaught") private fun readBleChunk(path: String): TrezorTransportReadResult { val connection = bleConnections[path] - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Device not open: $path" @@ -1020,17 +1021,17 @@ class TrezorTransport @Inject constructor( return try { val data = connection.readQueue.poll(BLE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Read timeout" ) Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") + transportReadResult(success = true, data = data, error = "") } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") + transportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") } } @@ -1045,14 +1046,14 @@ class TrezorTransport @Inject constructor( @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return transportWriteResult(success = false, error = "Device not open: $path") val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + ?: return transportWriteResult(success = false, error = "Write characteristic not available") if (!connection.isConnected) { Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) - return TrezorTransportWriteResult(success = false, error = "Device disconnected") + return transportWriteResult(success = false, error = "Device disconnected") } return try { @@ -1079,7 +1080,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { @@ -1092,7 +1093,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { @@ -1106,7 +1107,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } Logger.debug("BLE wrote '${data.size}' bytes to '$path' (attempt '$attempt')", context = TAG) @@ -1114,13 +1115,13 @@ class TrezorTransport @Inject constructor( // Small delay between writes to avoid overwhelming the GATT Thread.sleep(BLE_WRITE_INTER_DELAY_MS) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } - TrezorTransportWriteResult(success = false, error = lastError) + transportWriteResult(success = false, error = lastError) } catch (e: Exception) { Logger.error("BLE write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed") + transportWriteResult(success = false, error = e.message ?: "Write failed") } } @@ -1354,3 +1355,16 @@ class TrezorTransport @Inject constructor( bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } + +private fun transportWriteResult( + success: Boolean, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportWriteResult(success = success, error = error, errorCode = errorCode) + +private fun transportReadResult( + success: Boolean, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportReadResult(success = success, data = data, error = error, errorCode = errorCode) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d98a6e7734..75be73f0e2 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 @@ -1033,7 +1036,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1050,7 +1053,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, ) } } @@ -1592,7 +1595,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onExploreClick = { activity -> navController.navigateToActivityExplore(activity) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1849,9 +1852,17 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(activity: Activity) = + navigateTo(Routes.ActivityDetail(activity.rawId(), activity.walletId())) + +fun NavController.navigateToActivityItem(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityDetail(id, walletId)) + +fun NavController.navigateToActivityExplore(activity: Activity) = + navigateTo(Routes.ActivityExplore(activity.rawId(), activity.walletId())) -fun NavController.navigateToActivityExplore(id: String) = navigateTo(Routes.ActivityExplore(id)) +fun NavController.navigateToActivityExplore(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityExplore(id, walletId)) fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogDetail(fileName)) @@ -2072,13 +2083,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/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 6a0bdac66b..92c368fe6f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -18,7 +18,6 @@ import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice @@ -40,6 +39,7 @@ internal object TrezorPreviewData { initialized = true, needsBackup = false, passphraseEntryCapable = true, + unlocked = true, ) val sampleFeaturesMinimal = TrezorFeatures( @@ -55,6 +55,7 @@ internal object TrezorPreviewData { initialized = null, needsBackup = null, passphraseEntryCapable = null, + unlocked = null, ) val sampleKnownDevice = KnownDevice( @@ -257,6 +258,7 @@ internal object TrezorPreviewData { sent = 0uL, net = 100_000L, fee = null, + feeRate = null, amount = 100_000uL, direction = TxDirection.RECEIVED, blockHeight = 849_990u, @@ -269,6 +271,7 @@ internal object TrezorPreviewData { sent = 50_000uL, net = -50_000L, fee = 1_200uL, + feeRate = 8.0, amount = 48_800uL, direction = TxDirection.SENT, blockHeight = 849_995u, @@ -281,6 +284,7 @@ internal object TrezorPreviewData { sent = 5_000uL, net = 0L, fee = 500uL, + feeRate = 2.5, amount = 500uL, direction = TxDirection.SELF_TRANSFER, blockHeight = null, @@ -312,7 +316,6 @@ internal object TrezorPreviewData { activeWatcherId = "watcher-abc-123", connectionStatus = WatcherConnectionStatus.CONNECTED, balance = sampleWalletBalance, - transactions = sampleHistoryTransactions.toImmutableList(), transactionCount = 2u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index b4772d9785..6037c7ca78 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -9,7 +9,6 @@ import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorScriptType @@ -31,6 +30,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork @@ -68,7 +68,6 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( balance = event.balance, - transactions = event.transactions.toImmutableList(), transactionCount = event.txCount, blockHeight = event.blockHeight, accountType = event.accountType, @@ -714,6 +713,7 @@ class TrezorViewModel @Inject constructor( } val result = trezorRepo.startWatcher( watcherId = watcherId, + walletId = ActivityWalletType.TREZOR.scopedId(watcherId), extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, @@ -776,7 +776,6 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - transactions = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -956,9 +955,6 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherTransactions: ImmutableList - get() = watcher.transactions - val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1035,7 +1031,6 @@ data class TrezorWatcherState( val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, - val transactions: ImmutableList = persistentListOf(), val transactionCount: UInt = 0u, val blockHeight: UInt = 0u, val accountType: AccountType? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index aeaabafc89..2ef0e12ce8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -24,14 +24,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.TxDirection import to.bitkit.models.safe import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Footnote -import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer @@ -171,52 +169,6 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { } } - if (uiState.watcherTransactions.isNotEmpty()) { - VerticalSpacer(12.dp) - Caption13Up( - text = "Transactions (${uiState.watcherTransactions.size})", - color = Colors.White64, - ) - VerticalSpacer(4.dp) - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), - ) { - items(uiState.watcherTransactions) { tx -> - val directionLabel = when (tx.direction) { - TxDirection.SENT -> "Sent" - TxDirection.RECEIVED -> "Recv" - TxDirection.SELF_TRANSFER -> "Self" - } - val directionColor = when (tx.direction) { - TxDirection.SENT -> Colors.Red - TxDirection.RECEIVED -> Colors.Green - TxDirection.SELF_TRANSFER -> Colors.White64 - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Caption( - text = "$directionLabel ${tx.amount} sats", - color = directionColor, - ) - HorizontalSpacer(8.dp) - Caption( - text = "${tx.txid.take(8)}...${tx.txid.takeLast(8)}", - color = Colors.White50, - ) - HorizontalSpacer(8.dp) - Caption( - text = "${tx.confirmations} conf", - color = Colors.White50, - ) - } - } - } - } - if (uiState.watcherEvents.isNotEmpty()) { VerticalSpacer(12.dp) Caption13Up( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 9b1d45116f..159bafd790 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -39,7 +39,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BalanceHeaderView @@ -62,7 +62,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( deviceId: String, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), @@ -95,7 +95,7 @@ fun HardwareWalletScreen( private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,7 @@ private fun HardwareWalletContent( // Every activity here belongs to the watch-only device, so render them all with the blue // hardware icon, matching the home list. - val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.rawId() }.toImmutableSet() } + val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index cc3ea5f20d..7c8e4d1fc5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (Activity) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, @@ -588,7 +588,7 @@ private fun WalletPage( onRefresh: () -> Unit, onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, - onNavigateToActivityItem: (String) -> Unit, + onNavigateToActivityItem: (Activity) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 45efea812a..dd9d5cd21e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -62,7 +62,7 @@ fun SavingsWalletScreen( onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2cedaeafb5..9432a30df7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -63,7 +63,7 @@ fun SpendingWalletScreen( channels: ImmutableList, lightningActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 4ce821aa56..bef09564c3 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 @@ -58,6 +58,7 @@ import to.bitkit.R import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.isHardwareWalletActivity import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -65,6 +66,7 @@ import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -106,7 +108,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (String) -> Unit, + onExploreClick: (Activity) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -115,8 +117,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 @@ -178,6 +180,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity + val isHardware = remember(item) { item.isHardwareWalletActivity() } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -246,8 +249,8 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, - isHardware = uiState.isHardwareActivity, - showContactActions = isPaykitEnabled && !uiState.isHardwareActivity, + isHardware = isHardware, + showContactActions = isPaykitEnabled && !isHardware, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -325,7 +328,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (String) -> Unit, + onExploreClick: (Activity) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -598,7 +601,8 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware + // Hardware-wallet activities are first-class Bitkit Core activities, so they support tags too. + val showTagAction = true if (showContactActions || showTagAction) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -723,7 +727,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, + onClick = { onExploreClick(item) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), 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 286d4b3a96..7420158680 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 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..9ff6deeb7b 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 @@ -29,6 +29,8 @@ 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.ext.walletId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +49,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 +84,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.walletId()}_${item.rawId()}" + is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" } else -> "item_$index" @@ -120,7 +122,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 +167,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 +182,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.walletId()}_${item.rawId()}" + is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" } else -> "item_$index" @@ -217,7 +219,7 @@ fun LazyListScope.activityListGroupedItems( item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 5af0ba3173..2a220d64cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -32,7 +32,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -51,7 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a26a324811..3ca8c0f154 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -32,7 +32,6 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer -import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType @@ -65,7 +64,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (Activity) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -111,7 +110,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..9735df1108 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -25,9 +25,9 @@ import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId +import to.bitkit.ext.walletId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -40,7 +40,6 @@ class ActivityDetailViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, - private val hwWalletRepo: HwWalletRepo, private val transferRepo: TransferRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) @@ -58,23 +57,29 @@ class ActivityDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(activityLoadState = ActivityLoadState.Loading) } - activityRepo.getActivity(activityId) + activityRepo.getActivity(activityId, walletId) .onSuccess { activity -> if (activity != null) { this@ActivityDetailViewModel.activity = activity _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) } loadTags() - observeActivityChanges(activityId) + observeActivityChanges(activityId, walletId) } else { - loadHwWalletActivity(activityId) + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } } } .onFailure { e -> - Logger.error("Failed to load activity $activityId", e, TAG) + Logger.error("Failed to load activity '$activityId'", e, context = TAG) _uiState.update { it.copy( activityLoadState = ActivityLoadState.Error( @@ -89,57 +94,22 @@ class ActivityDetailViewModel @Inject constructor( fun clearActivityState() { observeJob?.cancel() observeJob = null - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } + _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) } activity = null _tags.update { persistentListOf() } } - private fun loadHwWalletActivity(activityId: String) { - val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } - if (hwActivity != null) { - activity = hwActivity - _uiState.update { - it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) - } - observeHwWalletActivityChanges(activityId) - } else { - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) - ) - ) - } - } - } - - private fun observeHwWalletActivityChanges(activityId: String) { - observeJob?.cancel() - observeJob = viewModelScope.launch(bgDispatcher) { - hwWalletRepo.activities.collect { activities -> - val updatedActivity = activities.find { it.rawId() == activityId } ?: return@collect - activity = updatedActivity - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Success(updatedActivity), - isHardwareActivity = true, - ) - } - } - } - } - - private fun observeActivityChanges(activityId: String) { + private fun observeActivityChanges(activityId: String, walletId: String?) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { activityRepo.activitiesChanged.collect { - reloadActivity(activityId) + reloadActivity(activityId, walletId) } } } - private suspend fun reloadActivity(activityId: String) { - activityRepo.getActivity(activityId) + private suspend fun reloadActivity(activityId: String, walletId: String?) { + activityRepo.getActivity(activityId, walletId) .onSuccess { updatedActivity -> if (updatedActivity != null) { activity = updatedActivity @@ -150,20 +120,21 @@ class ActivityDetailViewModel @Inject constructor( } } .onFailure { error -> - Logger.warn("Failed to reload activity $activityId", error, context = TAG) + Logger.warn("Failed to reload activity '$activityId'", error, context = TAG) // Keep showing the last known state on reload failure } } fun loadTags() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getActivityTags(id) + activityRepo.getActivityTags(id, walletId) .onSuccess { activityTags -> _tags.update { activityTags.toImmutableList() } } .onFailure { - Logger.error("Failed to load tags for activity $id", it, TAG) + Logger.error("Failed to load tags for activity '$id'", it, context = TAG) _tags.update { persistentListOf() } } } @@ -171,39 +142,42 @@ class ActivityDetailViewModel @Inject constructor( fun removeTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.removeTagsFromActivity(id, listOf(tag)) + activityRepo.removeTagsFromActivity(id, listOf(tag), walletId) .onSuccess { loadTags() } .onFailure { - Logger.error("Failed to remove tag $tag from activity $id", it, TAG) + Logger.error("Failed to remove tag '$tag' from activity '$id'", it, context = TAG) } } } fun addTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.addTagsToActivity(id, listOf(tag)) + activityRepo.addTagsToActivity(id, listOf(tag), walletId) .onSuccess { settingsStore.addLastUsedTag(tag) loadTags() } .onFailure { - Logger.error("Failed to add tag $tag to activity $id", it, TAG) + Logger.error("Failed to add tag '$tag' to activity '$id'", it, context = TAG) } } } fun detachContact() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id) + reloadActivity(id, walletId) }.onFailure { Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) } @@ -211,8 +185,9 @@ class ActivityDetailViewModel @Inject constructor( } fun fetchTransactionDetails(txid: String) { + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) + activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } } @@ -286,6 +261,5 @@ class ActivityDetailViewModel @Inject constructor( 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..476b2ddd1d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,15 +27,15 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isHardwareWalletActivity 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.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -46,7 +46,6 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, - private val hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -59,26 +58,12 @@ class ActivityListViewModel @Inject constructor( private val _onchainActivities = MutableStateFlow?>(null) val onchainActivities = _onchainActivities.asStateFlow() + // Hardware-wallet activities are persisted into Bitkit Core scoped by their walletId, so the + // unified list already includes them; no separate in-memory merge is needed. private val _latestActivities = MutableStateFlow?>(null) - private val _localActivityIds = 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) - if (localActivities == null && visibleHardwareActivities.isEmpty()) { - null - } else { - (localActivities.orEmpty() + visibleHardwareActivities) - .sortedByDescending { it.timestamp() } - .take(SIZE_LATEST) - .toImmutableList() - } - }.stateInScope(null) + val latestActivities: StateFlow?> = _latestActivities.asStateFlow() + + private val _hardwareIds = MutableStateFlow>(persistentSetOf()) val contacts: StateFlow> = combine( @@ -91,15 +76,7 @@ class ActivityListViewModel @Inject constructor( val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) - val hardwareIds: StateFlow> = combine( - hwWalletRepo.activities, - _localActivityIds, - ) { activities, localActivityIds -> - activities.withoutLocalDuplicates(localActivityIds) - .map { it.rawId() } - .toImmutableSet() - } - .stateInScope(persistentSetOf()) + val hardwareIds: StateFlow> = _hardwareIds.asStateFlow() private val _filters = MutableStateFlow(ActivityFilters()) @@ -143,52 +120,20 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, - hwWalletRepo.activities, - _localActivityIds, - ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, localActivityIds -> + ) { debouncedSearch, filtersWithoutSearch, _ -> val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) - fetchFilteredActivities(filters)?.let { activities -> - (activities + hardwareActivities.withoutLocalDuplicates(localActivityIds).filteredWith(filters)) - .sortedByDescending { it.timestamp() } - } + fetchFilteredActivities(filters)?.sortedByDescending { it.timestamp() } }.collect { activities -> _filteredActivities.update { activities?.toImmutableList() } } } - /** - * 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() - - val minTimestamp = filters.startDate?.let { (it / 1000).toULong() } - val maxTimestamp = filters.endDate?.let { (it / 1000).toULong() } - - return filter { activity -> - val matchesTab = when (filters.tab) { - ActivityTab.SENT -> activity.txType() == PaymentType.SENT - ActivityTab.RECEIVED -> activity.txType() == PaymentType.RECEIVED - else -> true - } - val matchesSearch = filters.searchText.isEmpty() || - activity.rawId().contains(filters.searchText, ignoreCase = true) - val timestamp = activity.timestamp() - val matchesDate = (minTimestamp == null || timestamp >= minTimestamp) && - (maxTimestamp == null || timestamp <= maxTimestamp) - matchesTab && matchesSearch && matchesDate - } - } - - private fun List.withoutLocalDuplicates(localActivityIds: Set) = filterNot { - it.rawId() in localActivityIds - } - private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) - _localActivityIds.update { filtered.map { it.rawId() }.toSet() } + _hardwareIds.update { + filtered.filter { it.isHardwareWalletActivity() }.map { it.scopedId() }.toImmutableSet() + } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } _onchainActivities.update { filtered.filterIsInstance().toImmutableList() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 915f9f8b86..f3298f0692 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -95,6 +95,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 @@ -331,6 +332,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2433,8 +2435,9 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { _transactionSheet.value.activityId?.let { + val walletId = _transactionSheet.value.activityWalletId hideNewTransactionSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it, walletId))) return } @@ -2451,7 +2454,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) @@ -2475,7 +2478,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/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 007a5b4688..679745586d 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -73,7 +73,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -85,7 +85,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", null) } @Test @@ -95,7 +95,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning( event = event, includeNotification = true, @@ -133,7 +133,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) - verify(activityRepo).markOnchainActivityAsSeen("txid456") + verify(activityRepo).markOnchainActivityAsSeen("txid456", null) } @Test @@ -172,7 +172,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) - verify(activityRepo).markOnchainActivityAsSeen("txid789") + verify(activityRepo).markOnchainActivityAsSeen("txid789", null) } } @@ -190,7 +190,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut(command) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), anyOrNull()) } @Test @@ -200,14 +200,14 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).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(), anyOrNull()) } @Test @@ -217,7 +217,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen("paymentId123")).thenReturn(true) + whenever(activityRepo.isActivitySeen("paymentId123", null)).thenReturn(true) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -225,7 +225,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(), anyOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d65386de54..92240f41ec 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -12,18 +12,21 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeastOnce 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.models.ActivityWalletType import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -34,8 +37,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val hwWalletRepo = mock() private val transferRepo = mock() + private val hardwareWalletId = ActivityWalletType.TREZOR.scopedId("dev1") companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -48,7 +51,6 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.wallet__activity_error_load_failed)).thenReturn("Failed to load activity") whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) runBlocking { whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null)) } @@ -59,13 +61,12 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, - hwWalletRepo = hwWalletRepo, transferRepo = transferRepo, ) } @Test - fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { + fun `loadActivity resolves a hardware wallet activity and tags it via its wallet id`() = test { val hwActivity = Activity.Onchain( OnchainActivity.create( id = ACTIVITY_ID, @@ -76,51 +77,36 @@ class ActivityDetailViewModelTest : BaseUnitTest() { address = "", timestamp = 1_700_000_000uL, confirmed = true, + walletId = hardwareWalletId, ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) - - sut.loadActivity(ACTIVITY_ID) - - val state = sut.uiState.value - val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success + whenever { activityRepo.getActivity(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(hwActivity)) + whenever { activityRepo.getActivityTags(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + }.thenReturn(Result.success(Unit)) + whenever { settingsStore.addLastUsedTag("tag1") }.thenReturn(Unit) + + sut.loadActivity(ACTIVITY_ID, hardwareWalletId) + val loadState = sut.uiState.value.activityLoadState + assertTrue(loadState is ActivityDetailViewModel.ActivityLoadState.Success) assertEquals(hwActivity, loadState.activity) - assertTrue(state.isHardwareActivity) - } - @Test - fun `hardware wallet activity updates while loaded`() = test { - val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) - val updatedActivity = createTestActivity(ACTIVITY_ID, confirmed = true) - val hardwareActivities = MutableStateFlow(persistentListOf(initialActivity)) + sut.addTag("tag1") - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) - - sut.loadActivity(ACTIVITY_ID) - - val initialState = sut.uiState.value.activityLoadState - assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) - - hardwareActivities.value = persistentListOf(updatedActivity) - - val updatedState = sut.uiState.value.activityLoadState - assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) - assertTrue(sut.uiState.value.isHardwareActivity) + verify(activityRepo, atLeastOnce()).getActivity(ACTIVITY_ID, hardwareWalletId) + verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + verify(activityRepo, atLeastOnce()).getActivityTags(ACTIVITY_ID, hardwareWalletId) } @Test - fun `loadActivity reports not found when missing from database and hardware wallets`() = test { - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + fun `loadActivity reports not found when missing from the database`() = test { + whenever { activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull()) }.thenReturn(Result.success(null)) sut.loadActivity(ACTIVITY_ID) val state = sut.uiState.value assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) - assertFalse(state.isHardwareActivity) } @Test @@ -181,8 +167,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(initialActivity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(initialActivity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -193,7 +179,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertEquals(initialActivity, initialState.activity) // Simulate activity update - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(updatedActivity)) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(updatedActivity)) activitiesChangedFlow.value += 1 // Verify ViewModel reflects updated activity @@ -208,8 +194,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -232,14 +218,15 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) // Simulate reload failure - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Network error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Network error"))) activitiesChangedFlow.value += 1 // Verify last known state is preserved @@ -250,7 +237,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `loadActivity handles error gracefully`() = test { - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Database error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Database error"))) sut.loadActivity(ACTIVITY_ID) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 286fcdd677..2f110d03c7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,7 +15,6 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -29,6 +28,7 @@ import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock +import to.bitkit.models.ActivityWalletType import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -37,6 +37,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @Suppress("LargeClass") @OptIn(ExperimentalTime::class) @@ -62,6 +63,7 @@ class ActivityRepoTest : BaseUnitTest() { private val testActivity = mock { on { v1 } doReturn testActivityV1 } + private val hardwareWalletId = ActivityWalletType.TREZOR.scopedId("dev1") private val baseOnchainActivity = OnchainActivity.create( id = "base_activity_id", @@ -159,7 +161,7 @@ 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(), anyOrNull()) }.thenReturn(null) wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities( any>(), @@ -196,6 +198,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -254,7 +257,7 @@ 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, null) }.thenReturn(testActivity) val result = sut.getActivity(activityId) @@ -263,85 +266,78 @@ 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( - 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 passes wallet id to core lookup`() = test { + val activityId = "activity123" + wheneverBlocking { coreService.activity.getActivity(activityId, hardwareWalletId) }.thenReturn(testActivity) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.getActivity(activityId, hardwareWalletId) 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( - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, - confirmed = true, + assertEquals(testActivity, result.getOrThrow()) + verify(coreService.activity).getActivity(activityId, hardwareWalletId) + } + + @Test + fun `persistHardwareActivities upserts activities and transaction details`() = test { + val activity = Activity.Onchain( + OnchainActivity.create( + id = "hw-txid", + txType = PaymentType.RECEIVED, + txId = "hw-txid", + value = 10_000uL, + fee = 0uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + walletId = hardwareWalletId, + ) + ) + val details = BitkitCoreTransactionDetails( + walletId = hardwareWalletId, + txId = "hw-txid", + amountSats = 10_000L, + inputs = emptyList(), + outputs = emptyList(), ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + wheneverBlocking { coreService.activity.upsertList(listOf(activity)) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.upsertTransactionDetailsList(listOf(details)) }.thenReturn(Unit) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardwareActivities(listOf(activity), listOf(details)) 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).upsertList(listOf(activity)) + verify(coreService.activity).upsertTransactionDetailsList(listOf(details)) + } + + @Test + fun `persistHardwareActivities does nothing when both lists are empty`() = test { + val result = sut.persistHardwareActivities(emptyList(), emptyList()) + + assertTrue(result.isSuccess) + verify(coreService.activity, never()).upsertList(any()) + verify(coreService.activity, never()).upsertTransactionDetailsList(any()) + } + + @Test + fun `deleteActivitiesForWallet delegates to core delete by wallet id`() = test { + wheneverBlocking { coreService.activity.deleteByWalletId(hardwareWalletId) }.thenReturn(3u) + + val result = sut.deleteActivitiesForWallet(hardwareWalletId) + + assertTrue(result.isSuccess) + verify(coreService.activity).deleteByWalletId(hardwareWalletId) } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { coreService.activity.getActivity(activityId, null) }.thenReturn(null) val result = sut.getActivity(activityId) assertTrue(result.isSuccess) assertNull(result.getOrThrow()) + verify(coreService.activity, never()).get(walletId = null) } @Test @@ -496,7 +492,7 @@ class ActivityRepoTest : BaseUnitTest() { // Verify tags are added to the new activity verify(coreService.activity).appendTags(activityId, tagsMock) // Verify delete is NOT called - verify(coreService.activity, never()).delete(any()) + verify(coreService.activity, never()).delete(any(), anyOrNull()) // Verify addActivityToDeletedList is NOT called verify(cacheStore, never()).addActivityToDeletedList(any()) } @@ -629,7 +625,7 @@ class ActivityRepoTest : BaseUnitTest() { val result = sut.addTagsToActivity(activityId, duplicateTags) assertTrue(result.isSuccess) - verify(coreService.activity, never()).appendTags(any(), any()) + verify(coreService.activity, never()).appendTags(any(), any(), anyOrNull()) } @Test @@ -771,6 +767,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +820,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +873,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +925,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +978,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), 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 50cf626b1d..55f3793e9f 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -3,18 +3,16 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent 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 @@ -27,11 +25,14 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.HwWalletData import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice @@ -42,10 +43,9 @@ 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 +import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Suppress("LargeClass") @@ -55,12 +55,12 @@ class HwWalletRepoTest : BaseUnitTest() { 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 private lateinit var trezorState: MutableStateFlow private lateinit var watcherEvents: MutableSharedFlow> + private val trezorWalletId = ActivityWalletType.TREZOR.scopedId("dev1") private val device = KnownDevice( id = "dev1", @@ -71,6 +71,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = trezorWalletId, ) @Before @@ -83,10 +84,11 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) - runBlocking { - whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) - } - whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) + wheneverBlocking { + trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + }.thenReturn(Result.success(Unit)) + wheneverBlocking { activityRepo.persistHardwareActivities(any(), any()) }.thenReturn(Result.success(Unit)) + wheneverBlocking { activityRepo.deleteActivitiesForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -94,7 +96,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -139,16 +140,15 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `transactions changed event sets device balance and maps activity`() = test { + fun `transactions changed event sets balance, exposes activities and persists them`() = test { val sut = createRepo() + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 10_562_411uL), - transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, txCount = 1u, - blockHeight = 850_000u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -156,143 +156,97 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(10_562_411uL, wallet.balanceSats) assertEquals(10_562_411uL, sut.totalSats.value) 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) + assertEquals("t1", (wallet.activities.single() as Activity.Onchain).v1.txId) + verify(activityRepo).persistHardwareActivities(listOf(activity), emptyList()) } @Test - fun `balances from multiple address-type watchers are summed per device`() = test { + fun `transactions changed event from inactive watcher is ignored`() = test { val sut = createRepo() + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, + "random|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, + txCount = 1u, ) ) - val wallet = sut.wallets.value.single() - assertEquals(150uL, wallet.balanceSats) - assertEquals(100uL, wallet.fundingBalanceSats) - assertEquals(150uL, sut.totalSats.value) + assertEquals(0uL, sut.totalSats.value) + verify(activityRepo, never()).persistHardwareActivities(listOf(activity), emptyList()) } @Test - fun `merges duplicate tx activities from multiple address-type watchers`() = test { + fun `transactions changed event is not exposed when persistence fails`() = test { + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) + wheneverBlocking { activityRepo.persistHardwareActivities(listOf(activity), emptyList()) } + .thenReturn(Result.failure(AppError("persist failed"))) val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, ) ) - 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(0uL, sut.totalSats.value) + assertEquals(emptyList(), sut.wallets.value.single().activities) } @Test - fun `merges duplicate tx activities across hardware wallets`() = test { - val secondDevice = device.copy( - id = "dev2", - path = "ble:CC:DD", - lastConnectedAt = 1L, - xpubs = mapOf("nativeSegwit" to "zpubNS2"), + fun `balances from multiple address-type watchers are summed per device`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) ) - storeData.value = HwWalletData(knownDevices = listOf(device, secondDevice)) - wheneverStartWatcher().thenReturn(Result.success(Unit)) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT) ) watcherEvents.emit( - "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|taproot" to transactionsChanged(balanceTotal = 50uL, accountType = AccountType.TAPROOT) ) - val activity = sut.activities.value.single() as Activity.Onchain - assertEquals(2, sut.wallets.value.size) - assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) + val wallet = sut.wallets.value.single() + assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.fundingBalanceSats) + assertEquals(150uL, sut.totalSats.value) } @Test - fun `preserves generated timestamp for pending tx refreshes`() = test { - whenever(clock.now()) - .thenReturn(Instant.fromEpochSeconds(1_800_000_000)) - .thenReturn(Instant.fromEpochSeconds(1_800_000_060)) - val sut = createRepo() - val pendingTx = receivedTransaction(amount = 100uL).copy( - txid = "pending", - blockHeight = null, - timestamp = null, - confirmations = 0u, + fun `merges duplicate tx activities from multiple address-type watchers`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) + val sut = createRepo() + val native = onchainActivity(txid = "shared", amount = 100uL) + val taproot = onchainActivity(txid = "shared", amount = 50uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), - txCount = 1u, - blockHeight = 1u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(native), + balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) - val firstTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), - txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, + "dev1|taproot" to transactionsChanged( + activities = listOf(taproot), + balanceTotal = 50uL, + accountType = AccountType.TAPROOT, ) ) - val refreshedTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - assertEquals(1_800_000_000uL, firstTimestamp) - assertEquals(firstTimestamp, refreshedTimestamp) + val activity = sut.wallets.value.single().activities.single() as Activity.Onchain + assertEquals(PaymentType.RECEIVED, activity.v1.txType) + assertEquals("shared", activity.v1.txId) + assertEquals(150uL, activity.v1.value) + assertEquals(150uL, sut.wallets.value.single().balanceSats) } @Test @@ -313,9 +267,12 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify( + trezorRepo, + never() + ).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) } @Test @@ -328,9 +285,10 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = eq(electrumServer), ) @@ -353,9 +311,10 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = eq(secondServer), ) @@ -367,12 +326,20 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, times(2)).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + walletId = any(), + extendedKey = any(), + network = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) } @Test @@ -383,42 +350,36 @@ class HwWalletRepoTest : BaseUnitTest() { // Baseline: full history delivered on watcher start must not emit. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL)), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t1", amount = 100uL)), + balanceTotal = 100uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, ) ) assertEquals(0, received.size) // New inbound tx after the baseline emits once. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf( + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), + balanceTotal = 150uL, txCount = 2u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL, walletId = trezorWalletId)), received) // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf( + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), + balanceTotal = 150uL, txCount = 2u, - blockHeight = 3u, - accountType = AccountType.NATIVE_SEGWIT, ) ) assertEquals(1, received.size) @@ -428,49 +389,37 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `emits received tx once when multiple watchers report the same new tx`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) + ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) val sut = createRepo() val received = mutableListOf() val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 0uL, accountType = AccountType.NATIVE_SEGWIT) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, - ) + "dev1|taproot" to transactionsChanged(balanceTotal = 0uL, accountType = AccountType.TAPROOT) ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 2u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "shared", amount = 100uL)), + balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 2u, + "dev1|taproot" to transactionsChanged( + activities = listOf(onchainActivity(txid = "shared", amount = 100uL)), + balanceTotal = 50uL, accountType = AccountType.TAPROOT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL, walletId = trezorWalletId)), received) job.cancel() } @@ -481,23 +430,13 @@ class HwWalletRepoTest : BaseUnitTest() { val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 40uL), - transactions = listOf( - receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), - ), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t3", amount = 60uL, txType = PaymentType.SENT)), + balanceTotal = 40uL, txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -514,16 +453,22 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher( + watcherId = eq("usb1|nativeSegwit"), + walletId = any(), + extendedKey = any(), + network = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) watcherEvents.emit( - "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 421_900uL), - transactions = listOf(receivedTransaction(amount = 421_900uL)), + "ble1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t1", amount = 421_900uL)), + balanceTotal = 421_900uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -565,13 +510,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) // Stop fails: the watcher data must survive so the balance is not silently wrong. @@ -594,13 +533,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) sut.resetState() @@ -611,7 +544,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `removeDevice stops the device watchers and forgets it`() = test { + fun `removeDevice stops the device watchers, forgets it and purges its activities`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) @@ -624,6 +557,23 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") + verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) + } + + @Test + fun `removeDevice fails when activity purge fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteActivitiesForWallet(trezorWalletId) } + .thenReturn(Result.failure(AppError("purge failed"))) + val sut = createRepo() + runCurrent() + + val result = sut.removeDevice("dev1") + + assertEquals(true, result.isFailure) + verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) } @Test @@ -698,9 +648,33 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, times(2)).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = any(), network = any(), - gapLimit = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) + } + + @Test + fun `restarts active watchers when wallet id changes`() = test { + val newWalletId = ActivityWalletType.TREZOR.scopedId("new-wallet") + whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = newWalletId))) + runCurrent() + + assertEquals(0uL, sut.totalSats.value) + verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + walletId = eq(newWalletId), + extendedKey = eq("zpubNS"), + network = eq(Env.network.toCoreNetwork()), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = any(), ) @@ -858,36 +832,15 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).startWatcher( watcherId = any(), + walletId = any(), extendedKey = any(), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = any(), ) } - private fun walletBalance(total: ULong) = WalletBalance( - confirmed = total, - immature = 0uL, - trustedPending = 0uL, - untrustedPending = 0uL, - spendable = total, - total = total, - ) - - private fun receivedTransaction(amount: ULong) = HistoryTransaction( - txid = "t1", - received = amount, - sent = 0uL, - net = amount.toLong(), - fee = null, - amount = amount, - direction = TxDirection.RECEIVED, - blockHeight = 850_000u, - timestamp = 1_700_000_000uL, - confirmations = 3u, - ) - @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) @@ -966,6 +919,51 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals("My Cold Wallet", sut.wallets.value.single().name) } + private fun walletBalance(total: ULong) = WalletBalance( + confirmed = total, + immature = 0uL, + trustedPending = 0uL, + untrustedPending = 0uL, + spendable = total, + total = total, + ) + + private fun onchainActivity( + txid: String, + amount: ULong, + txType: PaymentType = PaymentType.RECEIVED, + walletId: String = trezorWalletId, + ): Activity = Activity.Onchain( + OnchainActivity.create( + id = txid, + txType = txType, + txId = txid, + value = amount, + fee = 0uL, + address = "", + timestamp = 1_700_000_000uL, + confirmed = true, + walletId = walletId, + ) + ) + + @Suppress("LongParameterList") + private fun transactionsChanged( + activities: List = emptyList(), + transactionDetails: List = emptyList(), + balanceTotal: ULong = 0uL, + txCount: UInt = activities.size.toUInt(), + blockHeight: UInt = 1u, + accountType: AccountType = AccountType.NATIVE_SEGWIT, + ) = WatcherEvent.TransactionsChanged( + activities = activities, + transactionDetails = transactionDetails, + balance = walletBalance(balanceTotal), + txCount = txCount, + blockHeight = blockHeight, + accountType = accountType, + ) + private suspend fun wheneverStartWatcher() = whenever( trezorRepo.startWatcher( any(), @@ -973,6 +971,7 @@ class HwWalletRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), + anyOrNull(), any(), ) ) diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index 139405fab3..286a249bf9 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking +import to.bitkit.models.ActivityWalletType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -36,6 +37,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { private var timestampCounter = 0L private val testMetadata = PreActivityMetadata( + walletId = ActivityWalletType.BITKIT.id, paymentId = "payment-123", createdAt = 1234567890uL, tags = listOf("tag1", "tag2"), diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 68104ccb9c..1e1cd845d0 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -623,6 +623,7 @@ class TransferRepoTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull() ) ) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 9c0dc0f612..03eb3c355d 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -35,6 +35,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork @@ -43,7 +44,6 @@ import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError -import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -95,7 +95,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) whenever(trezorTransport.disconnectDevice(any())).thenReturn( - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) @@ -195,8 +195,8 @@ class TrezorRepoTest : BaseUnitTest() { } @Test - fun `initialize assigns wallet ids to restored devices missing them`() = test { - val knownDevice = mockKnownDevice(walletId = "") + fun `initialize derives wallet ids from xpubs for restored devices missing them`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = mapOf("nativeSegwit" to "zpubNS")) whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) sut = createSut() @@ -207,10 +207,25 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) val saved = savedCaptor.firstValue.single() assertEquals(knownDevice.id, saved.id) - assertNotNull(UUID.fromString(saved.walletId)) + assertTrue(ActivityWalletType.TREZOR.owns(saved.walletId)) + assertEquals(ActivityWalletType.TREZOR.deriveId(listOf("zpubNS")), saved.walletId) assertEquals(listOf(saved), sut.state.value.knownDevices) } + @Test + fun `initialize leaves wallet id blank for restored devices without xpubs`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = emptyMap()) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + // No xpubs means Core cannot derive an id yet, so it stays blank and nothing is re-saved. + assertEquals("", sut.state.value.knownDevices.single().walletId) + verify(hwWalletStore, never()).saveKnownDevices(any()) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -578,7 +593,41 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) - assertNotNull(UUID.fromString(saved.walletId)) + // No account xpubs were read in this flow, so Core cannot derive an id yet. + assertEquals("", saved.walletId) + } + + @Test + fun `connect derives a deterministic wallet id from captured xpubs`() = test { + val nativeSegwitPath = "m/84'/1'/0'" + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "captured-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val saved = captor.firstValue.single() + assertEquals(ActivityWalletType.TREZOR.deriveId(listOf("captured-native-xpub")), saved.walletId) } @Test @@ -622,6 +671,47 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } + @Test + fun `connect derives new wallet id when same device id has different xpub identity`() = test { + val oldWalletId = ActivityWalletType.TREZOR.deriveId(listOf("old-native-xpub")) + val newWalletId = ActivityWalletType.TREZOR.deriveId(listOf("new-native-xpub")) + val nativeSegwitPath = "m/84'/1'/0'" + val previousDevice = mockKnownDevice( + id = DEVICE_ID, + path = DEVICE_PATH, + xpubs = mapOf("nativeSegwit" to "old-native-xpub"), + walletId = oldWalletId, + ) + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "new-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(newWalletId, captor.firstValue.single().walletId) + } + @Test fun `connect preserves stored xpubs when account xpub refresh is partial`() = test { val previousXpubs = mapOf( diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index 66d1a9d944..0d06a5de27 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -361,7 +361,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `startWatcher should not expose active watcher until start completes`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") @@ -391,13 +391,13 @@ class TrezorViewModelTest : BaseUnitTest() { sut.startWatcher() advanceUntilIdle() - verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any()) assertNull(sut.uiState.value.activeWatcherId) } @Test fun `watcher transaction event should mark watcher connected`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -406,7 +406,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = emptyList(), + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -424,7 +425,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `watcher event should be handled while start is in flight`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -433,7 +434,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = emptyList(), + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -458,7 +460,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") @@ -474,7 +476,7 @@ class TrezorViewModelTest : BaseUnitTest() { assertNull(state.activeWatcherId) assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) assertNull(state.watcherBalance) - assertTrue(state.watcherTransactions.isEmpty()) + assertEquals(0u, state.watcherTransactionCount) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..9544600c14 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -3,8 +3,6 @@ package to.bitkit.viewmodels import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -12,14 +10,16 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.ext.create import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId +import to.bitkit.models.ActivityWalletType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.ActivityState -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.activity.components.ActivityTab @@ -29,34 +29,38 @@ import kotlin.test.assertEquals class ActivityListViewModelTest : BaseUnitTest() { private val activityRepo = mock() - private val hwWalletRepo = mock() private val pubkyRepo = mock() 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 lateinit var hardwareActivities: MutableStateFlow> + + // Hardware-wallet activity scoped to its own walletId; the repo now returns it merged with local ones. + private val hwActivity = onchainActivity( + id = "hw1", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev1"), + ) @Before fun setUp() { - hardwareActivities = MutableStateFlow(persistentListOf(hwActivity)) 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.getActivities( - 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(Result.success(listOf(dbActivity))) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) + }.thenReturn(Result.success(listOf(dbActivity, hwActivity))) whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) } @@ -64,13 +68,12 @@ class ActivityListViewModelTest : BaseUnitTest() { private fun createViewModel() = ActivityListViewModel( bgDispatcher = testDispatcher, activityRepo = activityRepo, - hwWalletRepo = hwWalletRepo, pubkyRepo = pubkyRepo, settingsStore = settingsStore, ) @Test - fun `filtered activities merge hardware activities newest first`() = test { + fun `filtered activities include hardware activities newest first`() = test { val sut = createViewModel() advanceUntilIdle() @@ -79,6 +82,20 @@ class ActivityListViewModelTest : BaseUnitTest() { @Test fun `filtered activities exclude hardware activities not matching the tab`() = test { + // Core filters by txType, so the SENT tab query returns only the SENT db activity. + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(dbActivity))) val sut = createViewModel() sut.setTab(ActivityTab.SENT) advanceUntilIdle() @@ -87,40 +104,44 @@ class ActivityListViewModelTest : BaseUnitTest() { } @Test - fun `filtered activities exclude hardware activities when a tag filter is active`() = test { + fun `hardware activity is included under an active tag filter`() = test { + // A tagged hardware activity must still appear: it now lives in Core and is returned by the query. + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(hwActivity))) val sut = createViewModel() sut.toggleTag("tag1") advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals(listOf("hw1"), sut.filteredActivities.value?.map { it.rawId() }) } @Test - fun `hardware ids expose the hardware activity ids`() = test { + fun `hardware ids expose the ids of activities scoped to a hardware wallet`() = test { val sut = createViewModel() val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(setOf("hw1"), sut.hardwareIds.value) - job.cancel() - } - - @Test - fun `hardware duplicates of local activities are excluded`() = test { - hardwareActivities.value = persistentListOf( - hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), - ) - 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(setOf(hwActivity.scopedId()), sut.hardwareIds.value) job.cancel() } - private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( + private fun onchainActivity( + id: String, + txType: PaymentType, + timestamp: ULong, + walletId: String = ActivityWalletType.BITKIT.id, + ) = Activity.Onchain( OnchainActivity.create( id = id, txType = txType, @@ -130,6 +151,7 @@ class ActivityListViewModelTest : BaseUnitTest() { address = "bc1", timestamp = timestamp, confirmed = true, + walletId = walletId, ) ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8561752f91..f185838161 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -38,6 +38,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.models.ActivityWalletType import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.PubkyProfile @@ -276,16 +277,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" + val walletId = ActivityWalletType.TREZOR.scopedId("dev1") sut.mainScreenEffect.test { advanceUntilIdle() - hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL)) + hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL, walletId = walletId)) advanceUntilIdle() assertEquals(txId, sut.transactionSheet.value.activityId) + assertEquals(walletId, sut.transactionSheet.value.activityWalletId) sut.onClickActivityDetail() - assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId)), awaitItem()) + assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId, walletId)), awaitItem()) } verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) } diff --git a/changelog.d/next/1029.changed.md b/changelog.d/next/1029.changed.md new file mode 100644 index 0000000000..fcadac60aa --- /dev/null +++ b/changelog.d/next/1029.changed.md @@ -0,0 +1 @@ +Hardware wallet transactions are now first-class activity entries: they can be tagged, show their input and output details, and appear in the activity list under tag and tab filters alongside your normal Bitkit transactions. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40ca00380e..89cd1164aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.73" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.4" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index b823c0b83e..252d43138c 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,7 +60,8 @@ 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` | Hardware activity in the unified list, blue icons, All Activity tab filters | +| `activity-detail-hw-tags.xml` | Hardware activity detail tags (persist + survive tag filter) 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 | diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c0..34b0032ab8 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,11 +1,11 @@ - 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 home list and the All Activity screen + with blue icon variants and tab filter behavior. Hardware activities are now first-class + Bitkit Core activities (persisted by the watcher), so they appear in the unified list and + survive tab and tag filters like normal transactions. 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). @@ -33,7 +33,7 @@ Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions - 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 back to the "All" tab and verify the blue-icon hardware items 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..c457a124cf --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,40 @@ + + + Verifies that a hardware-wallet transaction behaves as a first-class Bitkit Core activity: + its detail screen supports tags (which persist and keep the item visible under a tag + filter) and its Explore screen shows the transaction inputs and outputs fetched from the + configured Electrum backend. 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). Use a hardware seed distinct from the Bitkit wallet + seed so the transaction resolves as a hardware (blue-icon) activity, not a local one. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the first activity item with a blue (hardware) circular icon + + + Verify an activity detail screen opens showing a blue icon and an on-chain amount + + + Tap "Add Tag", enter the tag "hwtest" and confirm it + + + Verify a tag chip labelled "hwtest" is shown on the activity detail screen + + + Navigate back to the home screen, then tap the same blue-icon activity again and verify the "hwtest" tag is still shown (it persisted to Bitkit Core) + + + Tap "Explore", verify the Activity Explorer screen opens and shows an "Inputs" section and an "Outputs" section each listing at least one entry + + + Navigate back to the home screen, then tap "Show All" beneath the activity list + + + Open the tag filter, select the "hwtest" tag, and verify the blue-icon hardware activity remains listed in the filtered results + + +