diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..e573d162a4 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 + +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.activityKey(): String = when (this) { + is Activity.Lightning -> "lightning_${scopedId()}" + is Activity.Onchain -> "onchain_${scopedId()}" +} + +fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) + fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType @@ -35,6 +52,21 @@ fun Activity.totalValue() = when (this) { } } +fun Activity.value() = when (this) { + is Activity.Lightning -> v1.value + is Activity.Onchain -> v1.value +} + +fun Activity.fee() = when (this) { + is Activity.Lightning -> v1.fee + is Activity.Onchain -> v1.fee +} + +fun Activity.message() = when (this) { + is Activity.Lightning -> v1.message + is Activity.Onchain -> "" +} + fun Activity.isBoosted() = when (this) { is Activity.Onchain -> v1.isBoosted else -> false @@ -77,6 +109,21 @@ fun Activity.paymentState(): PaymentState? = when (this) { is Activity.Onchain -> null } +fun Activity.txId(): String? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.txId +} + +fun Activity.confirmed(): Boolean? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.confirmed +} + +fun Activity.feeRate(): ULong = when (this) { + is Activity.Lightning -> 0u + is Activity.Onchain -> v1.feeRate +} + fun Activity.Onchain.boostType() = when (this.v1.txType) { PaymentType.SENT -> BoostType.RBF PaymentType.RECEIVED -> BoostType.CPFP @@ -90,6 +137,11 @@ fun Activity.timestamp() = when (this) { } } +fun Activity.groupTimestamp() = when (this) { + is Activity.Lightning -> v1.timestamp + is Activity.Onchain -> v1.timestamp +} + enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") @@ -107,7 +159,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 +199,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..b3e70cea86 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,19 @@ +package to.bitkit.models + +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" + + 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..119c1333e0 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,43 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + 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), + val mergedActivities = activities.map { mergeExistingTransferMetadata(it) } + if (mergedActivities.isNotEmpty()) coreService.activity.upsertList(mergedActivities) + if (transactionDetails.isNotEmpty()) coreService.activity.upsertTransactionDetailsList(transactionDetails) + if (mergedActivities.isNotEmpty() || transactionDetails.isNotEmpty()) notifyActivitiesChanged() + }.onFailure { + Logger.error("Failed to persist hardware activities", it, context = TAG) + } + } + + private suspend fun mergeExistingTransferMetadata(activity: Activity): Activity { + val onchain = activity as? Activity.Onchain ?: return activity + val existing = coreService.activity.getOnchainActivityByTxId( + txId = onchain.v1.txId, + walletId = onchain.v1.walletId, + ) ?: return activity + if (!existing.isTransfer && existing.channelId == null) return activity + + return Activity.Onchain( + onchain.v1.copy( + isTransfer = onchain.v1.isTransfer || existing.isTransfer, + channelId = onchain.v1.channelId ?: existing.channelId, ) - if (updated == existing) return@runCatching - coreService.activity.update(existing.id, Activity.Onchain(updated)) + ) + } + + 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 +278,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 +351,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -337,7 +362,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 +380,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 +679,7 @@ class ActivityRepo @Inject constructor( insertActivity( Activity.Lightning( LightningActivity( + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -684,15 +710,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 +755,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 +777,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..15efc0d0cc 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,10 @@ 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.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -58,9 +56,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 +67,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 +86,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 +109,7 @@ class HwWalletRepo @Inject constructor( } activeWatchers.clear() activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() retryingWatcherStarts.clear() emittedReceivedTxIds.clear() _watcherData.update { emptyMap() } @@ -153,6 +149,15 @@ class HwWalletRepo @Inject constructor( suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + suspend fun getWalletId(deviceId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val device = requireNotNull(hwWalletStore.loadKnownDevices().find { it.id == deviceId }) { + "Unknown hardware wallet '$deviceId'" + } + device.walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(device.xpubs.values) + } + } + suspend fun getFundingAccount( deviceId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, @@ -278,6 +283,9 @@ 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" } + + val walletIdToPurge = target?.walletId?.takeIf { it.isNotBlank() } + if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge).getOrThrow() }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -327,21 +335,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 +344,26 @@ 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 } + 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) + + activityRepo.persistHardwareActivities(activities, transactionDetails).getOrElse { + Logger.error("Failed to persist hardware activities for watcher '$watcherId'", it, context = TAG) + return@collect } - emitReceivedTxs(previous, event, updatedWatcherData) + + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -378,21 +374,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 +410,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() } + ?: trezorRepo.deriveWalletId(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 +485,62 @@ 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 } + 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 +585,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 127a992b06..f073dea152 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -62,6 +62,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 @@ -1178,6 +1179,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/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 44464bd16c..368cf4d35b 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -16,6 +16,7 @@ import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid import to.bitkit.ext.runSuspendCatching @@ -103,6 +104,7 @@ class TransferRepo @Inject constructor( txId: String, fee: ULong, feeRate: ULong, + walletId: String = DEFAULT_WALLET_ID, ): Result = withContext(bgDispatcher) { runSuspendCatching { val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { @@ -116,6 +118,7 @@ class TransferRepo @Inject constructor( feeRate = feeRate, isTransfer = true, channelId = order.channel?.shortChannelId, + walletId = walletId, ) }.onFailure { Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) @@ -256,7 +259,22 @@ class TransferRepo @Inject constructor( } private suspend fun markActivityAsTransfer(txid: String, channelId: String) { - val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + val hardwareActivity = runSuspendCatching { + coreService.activity.get( + filter = ActivityFilter.ONCHAIN, + limit = 50u, + sortDirection = SortDirection.DESC, + ) + }.getOrNull().orEmpty() + .filterIsInstance() + .map { it.v1 } + .filter { it.txId == txid } + .firstOrNull { it.walletId != DEFAULT_WALLET_ID } + + val activity = hardwareActivity + ?: coreService.activity.getOnchainActivityByTxId(txid) + ?: return + if (activity.isTransfer && activity.channelId == channelId) return val updated = activity.copy(isTransfer = true, channelId = channelId) coreService.activity.update(activity.id, Activity.Onchain(updated)) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..23435f214c 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,8 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import java.util.UUID +import java.math.BigInteger +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -97,7 +99,15 @@ class TrezorRepo @Inject constructor( private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4 + private const val EXTENDED_KEY_VERSION_SIZE = 4 + private const val BASE58_CHECKSUM_SIZE = 4 + private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private const val BASE58_ZERO = '1' + private const val SHA_256 = "SHA-256" private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds + private val BASE58_RADIX = BigInteger.valueOf(58L) + private val XPUB_VERSION = byteArrayOf(0x04.toByte(), 0x88.toByte(), 0xB2.toByte(), 0x1E.toByte()) + private val TPUB_VERSION = byteArrayOf(0x04.toByte(), 0x35.toByte(), 0x87.toByte(), 0xCF.toByte()) } private val _state = MutableStateFlow(TrezorState()) @@ -641,6 +651,7 @@ class TrezorRepo @Inject constructor( suspend fun connectKnownDevice( deviceId: String, forceSession: Boolean = false, + refreshKnownDevice: Boolean = true, ): Result = withContext(ioDispatcher) { if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) @@ -675,7 +686,9 @@ class TrezorRepo @Inject constructor( Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) Logger.debug("Connected known device '${device.id}'", context = TAG) - addOrUpdateKnownDevice(device, features) + if (refreshKnownDevice) { + addOrUpdateKnownDevice(device, features) + } _state.update { it.copy(connected = ConnectedTrezorDevice(id = device.id, features = features)) } Logger.info("Reconnected known device '${device.id}'", context = TAG) features @@ -695,7 +708,7 @@ class TrezorRepo @Inject constructor( if (current?.id == deviceId && trezorService.isConnected()) { return@withContext Result.success(current.features) } - connectKnownDevice(deviceId, forceSession = false) + connectKnownDevice(deviceId, forceSession = false, refreshKnownDevice = false) } suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { @@ -737,9 +750,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 +761,7 @@ class TrezorRepo @Inject constructor( awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -762,6 +777,12 @@ class TrezorRepo @Inject constructor( } } + fun deriveWalletId(xpubs: Collection): String { + val xpubList = xpubs.map { it.toStandardExtendedKey() } + if (xpubList.isEmpty() || xpubList.any { it.isBlank() }) return "" + return trezorService.deriveWalletId(ActivityWalletType.TREZOR.id, xpubList) + } + suspend fun stopWatcher(watcherId: String): Result = withContext(ioDispatcher) { runCatching { awaitSetup() @@ -871,7 +892,7 @@ class TrezorRepo @Inject constructor( } private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { - val stored = hwWalletStore.loadKnownDevices() + val stored = hwWalletStore.loadKnownDevices().withStandardExtendedKeys() val storedIds = stored.map { it.id }.toSet() val knownDevices = stored + _state.value.knownDevices.filter { it.id !in storedIds } val previous = knownDevices.find { it.id == deviceInfo.id } @@ -886,7 +907,7 @@ class TrezorRepo @Inject constructor( lastConnectedAt = clock.nowMs(), xpubs = xpubs, customLabel = previous?.customLabel, - walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), + walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs, ::deriveWalletId), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -906,7 +927,7 @@ class TrezorRepo @Inject constructor( path = addressType.toAccountDerivationPath(network = Env.network), coin = coin, showOnTrezor = false, - ).xpub + ).xpub.toStandardExtendedKey() addressType.toSettingsString() to xpub }.onFailure { Logger.warn("Could not read xpub for '${addressType.toSettingsString()}'", it, context = TAG) @@ -914,10 +935,76 @@ class TrezorRepo @Inject constructor( }.toMap() } + private fun List.withStandardExtendedKeys(): List = map { device -> + val normalized = device.xpubs.mapValues { it.value.toStandardExtendedKey() } + if (normalized == device.xpubs) device else device.copy(xpubs = normalized) + } + + private fun String.toStandardExtendedKey(): String { + val version = when (take(EXTENDED_KEY_VERSION_SIZE)) { + "xpub", "tpub" -> return this + "ypub", "zpub" -> XPUB_VERSION + "upub", "vpub" -> TPUB_VERSION + else -> return this + } + val payload = decodeBase58Check().getOrNull() ?: return this + if (payload.size < EXTENDED_KEY_VERSION_SIZE) return this + + val normalized = payload.copyOf() + version.copyInto(normalized, endIndex = EXTENDED_KEY_VERSION_SIZE) + return normalized.encodeBase58Check() + } + + private fun String.decodeBase58Check(): Result = runCatching { + val decoded = decodeBase58() + check(decoded.size >= BASE58_CHECKSUM_SIZE) + val payload = decoded.copyOfRange(0, decoded.size - BASE58_CHECKSUM_SIZE) + val checksum = decoded.copyOfRange(decoded.size - BASE58_CHECKSUM_SIZE, decoded.size) + check(payload.sha256d().copyOfRange(0, BASE58_CHECKSUM_SIZE).contentEquals(checksum)) + payload + } + + private fun String.decodeBase58(): ByteArray { + var value = BigInteger.ZERO + forEach { + val digit = BASE58_ALPHABET.indexOf(it) + require(digit >= 0) + value = value.multiply(BASE58_RADIX).add(BigInteger.valueOf(digit.toLong())) + } + + val bytes = value.toByteArray().let { + if (it.size > 1 && it.first() == 0.toByte()) it.copyOfRange(1, it.size) else it + }.takeUnless { value == BigInteger.ZERO } ?: byteArrayOf() + return ByteArray(takeWhile { it == BASE58_ZERO }.length) + bytes + } + + private fun ByteArray.encodeBase58Check(): String { + val checksum = sha256d().copyOfRange(0, BASE58_CHECKSUM_SIZE) + return (this + checksum).encodeBase58() + } + + private fun ByteArray.encodeBase58(): String { + var value = BigInteger(1, this) + val encoded = StringBuilder() + while (value > BigInteger.ZERO) { + val divideAndRemainder = value.divideAndRemainder(BASE58_RADIX) + value = divideAndRemainder[0] + encoded.append(BASE58_ALPHABET[divideAndRemainder[1].toInt()]) + } + repeat(takeWhile { it == 0.toByte() }.size) { encoded.append(BASE58_ZERO) } + return encoded.reverse().toString() + } + + private fun ByteArray.sha256d(): ByteArray { + val digest = MessageDigest.getInstance(SHA_256) + return digest.digest(digest.digest(this)) + } + private suspend fun loadKnownDevices(): List = runCatching { - val devices = hwWalletStore.loadKnownDevices() - val migrated = devices.withHardwareWalletIds() - if (migrated != devices) { + val stored = hwWalletStore.loadKnownDevices() + val devices = stored.withStandardExtendedKeys() + val migrated = devices.withHardwareWalletIds(::deriveWalletId) + if (migrated != stored) { hwWalletStore.saveKnownDevices(migrated) } migrated @@ -1086,26 +1173,33 @@ private val KnownDevice.walletKey: String private fun walletKey(xpubs: Map, fallback: String): String = xpubs.values.sorted().joinToString().ifEmpty { fallback } -private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { +private fun List.findHardwareWalletId( + deviceId: String, + xpubs: Map, + deriveWalletId: (Collection) -> String, +): 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 deriveWalletId(xpubs.values) + + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() }.orEmpty() } -private fun List.withHardwareWalletIds(): List { +private fun List.withHardwareWalletIds( + deriveWalletId: (Collection) -> String, +): List { val existingByWallet = filter { it.walletId.isNotBlank() } .associate { it.walletKey to it.walletId } val generatedByWallet = mutableMapOf() return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { deriveWalletId(it.xpubs.values) } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() +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 b11bddae8d..631aad0290 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -31,6 +31,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 @@ -81,10 +82,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 @@ -242,10 +248,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + 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, @@ -256,15 +265,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) } @@ -277,9 +289,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( @@ -300,6 +317,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -307,16 +325,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 { @@ -329,6 +353,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -338,30 +363,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() @@ -388,26 +422,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 { @@ -510,7 +556,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 && @@ -550,7 +596,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)) @@ -890,24 +936,19 @@ 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) } } - - val existingOnchainActivity = existingActivity as? Activity.Onchain - if (existingOnchainActivity != null && - (existingOnchainActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && - !(existingOnchainActivity.v1.contact == null && payment.direction == PaymentDirection.INBOUND) - ) { - return + if (existingActivity == null) { + existingActivity = findAnyOnchainActivityByTxId(kind.txid) } + val existingOnchainActivity = existingActivity as? Activity.Onchain var resolvedChannelId = channelId - // Check if this transaction is a channel transfer if (resolvedChannelId == null) { val foundChannelId = findChannelForTransaction( txid = kind.txid, @@ -919,6 +960,18 @@ class ActivityService( } } + val needsTransferUpdate = existingOnchainActivity != null && + resolvedChannelId != null && + (!existingOnchainActivity.v1.isTransfer || existingOnchainActivity.v1.channelId == null) + + if (existingOnchainActivity != null && + (existingOnchainActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && + !(existingOnchainActivity.v1.contact == null && payment.direction == PaymentDirection.INBOUND) && + !needsTransferUpdate + ) { + return + } + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { @@ -962,6 +1015,20 @@ class ActivityService( } } + private fun findAnyOnchainActivityByTxId(txId: String): Activity.Onchain? { + return getActivities( + walletId = null, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = 50u, + sortDirection = SortDirection.DESC, + ).filterIsInstance().firstOrNull { it.v1.txId == txId } + } + private fun PaymentDirection.toPaymentType(): PaymentType = if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED @@ -1074,14 +1141,15 @@ class ActivityService( feeRate: ULong, isTransfer: Boolean, channelId: String?, + walletId: String = DEFAULT_WALLET_ID, ) { ServiceQueue.CORE.background { runCatching { - if (getOnchainActivityByTxId(txId = txid) != null) { + if (getOnchainActivityByTxId(txId = txid, walletId = walletId) != null) { Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background } - val now = System.currentTimeMillis().toULong() / 1000u + val now = nowTimestamp().epochSecond.toULong() val onchain = OnchainActivity.create( id = txid, txType = PaymentType.SENT, @@ -1097,6 +1165,7 @@ class ActivityService( createdAt = now, updatedAt = 0u, seenAt = now, + walletId = walletId, ) upsertActivity(Activity.Onchain(onchain)) Logger.info("Created sent onchain activity for txid $txid from send result", context = TAG) @@ -1389,43 +1458,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, @@ -1443,11 +1521,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..fd4f4d4f76 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 @@ -44,11 +45,8 @@ class TrezorBridgeTransport( private const val READ_TIMEOUT_MS = 30_000 private const val CALL_READ_TIMEOUT_MS = 120_000 - /** - * Trezor protobuf MessageType_SignTx. This is the only call that waits - * for on-device signing. - */ private const val SIGN_TX_MESSAGE_TYPE = 15 + private const val BUTTON_ACK_MESSAGE_TYPE = 27 } private val json = Json { ignoreUnknownKeys = true } @@ -97,29 +95,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 +125,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 +137,7 @@ class TrezorBridgeTransport( data: ByteArray, ): TrezorCallMessageResult { val session = openSessions[path] - ?: return TrezorCallMessageResult( + ?: return callMessageResult( success = false, messageType = 0u.toUShort(), data = byteArrayOf(), @@ -148,12 +146,18 @@ class TrezorBridgeTransport( return runCatching { val request = encodeFrame(messageType, data) - val timeoutMs = if (messageType == SIGN_TX_MESSAGE_TYPE.toUShort()) callReadTimeoutMs else readTimeoutMs + val timeoutMs = when (messageType) { + SIGN_TX_MESSAGE_TYPE.toUShort(), + BUTTON_ACK_MESSAGE_TYPE.toUShort(), + -> callReadTimeoutMs + + else -> readTimeoutMs + } val response = post("/call/${encode(session)}", request, readTimeoutMs = timeoutMs) 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 +188,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 +240,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/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9c..535b316b33 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -50,6 +50,7 @@ import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton import com.synonym.bitkitcore.Network as BitkitCoreNetwork +import com.synonym.bitkitcore.deriveWalletId as deriveCoreWalletId @Suppress("TooManyFunctions") @Singleton @@ -301,4 +302,8 @@ class TrezorService @Inject constructor( onchainStopAllWatchers() } } + + fun deriveWalletId(deviceType: String, xpubs: Collection): String { + return deriveCoreWalletId(deviceType = deviceType, xpubs = xpubs.toList()) + } } 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 2b1642a37a..a5765c87a6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -996,7 +996,7 @@ private fun NavGraphBuilder.home( isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1015,7 +1015,7 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1035,7 +1035,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1052,7 +1052,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1210,7 +1210,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1594,7 +1594,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onExploreClick = { id, walletId -> navController.navigateToActivityExplore(id, walletId) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1851,9 +1851,11 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityDetail(id, 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)) @@ -2074,13 +2076,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..8cc2dead5d 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: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name @@ -154,7 +154,7 @@ private fun Preview() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -170,7 +170,7 @@ private fun PreviewEmpty() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -186,7 +186,7 @@ private fun PreviewError() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } 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..9f3c8de043 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: (String, String) -> 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: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,8 @@ 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 activityItems = remember(wallet.activities) { wallet.activities } + val hardwareIds = remember(activityItems) { activityItems.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() @@ -189,7 +190,7 @@ private fun HardwareWalletContent( } activityListGroupedItems( - items = wallet.activities, + items = activityItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = {}, showFooter = false, @@ -264,7 +265,7 @@ private fun Preview() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -284,7 +285,7 @@ private fun PreviewNoActivity() { HardwareWalletContent( wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -304,7 +305,7 @@ private fun PreviewEmpty() { HardwareWalletContent( wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -324,7 +325,7 @@ private fun PreviewRemoveDialog() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = true, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, 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..083121d0c0 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 @@ -379,7 +379,7 @@ fun HomeScreen( onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, + onNavigateToActivityItem = { id, walletId -> rootNavController.navigateToActivityItem(id, walletId) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (String, String) -> 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: (String, String) -> 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..368aabc417 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: (String, String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, @@ -200,7 +200,7 @@ private fun Preview() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -220,7 +220,7 @@ private fun PreviewTransfer() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -243,7 +243,7 @@ private fun PreviewNoActivity() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -263,7 +263,7 @@ private fun PreviewGeoBlocked() { isGeoBlocked = true, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -283,7 +283,7 @@ private fun PreviewEmpty() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, 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..dbb8f3aaac 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: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, @@ -223,7 +223,7 @@ private fun Preview() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -244,7 +244,7 @@ private fun PreviewTransfer() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -268,7 +268,7 @@ private fun PreviewNoActivity() { channels = persistentListOf(createChannelDetails()), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -289,7 +289,7 @@ private fun PreviewEmpty() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -309,7 +309,7 @@ private fun PreviewEmptyWithSavings() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 2a295347c0..b044bcfb02 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 @@ -55,16 +55,27 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.confirmed import to.bitkit.ext.contact import to.bitkit.ext.create +import to.bitkit.ext.doesExist import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.fee +import to.bitkit.ext.feeRate +import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.txType +import to.bitkit.ext.value +import to.bitkit.ext.walletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -99,6 +110,7 @@ import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel +import to.bitkit.viewmodels.TransferOrderAmounts @Suppress("CyclomaticComplexMethod") @Composable @@ -106,7 +118,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -115,8 +127,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 +190,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity + val isHardware = remember(item.walletId()) { ActivityWalletType.TREZOR.owns(item.walletId()) } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -206,7 +219,7 @@ fun ActivityDetailScreen( } // Update boostTxDoesExist when boostTxIds change - LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { + LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else persistentListOf()) { if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) } @@ -246,8 +259,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( @@ -272,7 +285,8 @@ fun ActivityDetailScreen( (item as? Activity.Onchain)?.let { BoostTransactionSheet( onDismiss = detailViewModel::onDismissBoostSheet, - item = it, + activityId = it.rawId(), + walletId = it.walletId(), onSuccess = { app.toast( type = Toast.ToastType.SUCCESS, @@ -325,7 +339,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -350,30 +364,24 @@ private fun ActivityDetailContent( val amountPrefix = if (isSent) "-" else "+" val timestamp = item.timestamp() - val paymentValue = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> item.v1.value - } - val baseFee = when (item) { - is Activity.Lightning -> item.v1.fee - is Activity.Onchain -> item.v1.fee - } + val paymentValue = item.value() + val baseFee = item.fee() val isSelfSend = isSent && paymentValue == 0uL val channelId = (item as? Activity.Onchain)?.v1?.channelId val txId = (item as? Activity.Onchain)?.v1?.txId - var order by remember { mutableStateOf(null) } + var orderAmounts by remember { mutableStateOf(null) } LaunchedEffect(item, isTransferToSpending, detailViewModel) { - order = if (isTransferToSpending && detailViewModel != null) { - detailViewModel.findOrderForTransfer(channelId, txId) + orderAmounts = if (isTransferToSpending && detailViewModel != null) { + detailViewModel.findTransferOrderAmounts(channelId, txId) } else { null } } - val orderServiceFee: ULong? = order?.let { it.feeSat - it.clientBalanceSat } - val transferAmount: ULong? = order?.clientBalanceSat + val orderServiceFee: ULong? = orderAmounts?.serviceFee + val transferAmount: ULong? = orderAmounts?.transferAmount val fee: ULong? = when { isTransferToSpending && orderServiceFee != null && baseFee != null -> baseFee + orderServiceFee @@ -554,8 +562,8 @@ private fun ActivityDetailContent( ) // Note section for Lightning payments with message - if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { - val message = item.v1.message + if (item is Activity.Lightning && item.message().isNotEmpty()) { + val message = item.message() Column( modifier = Modifier .fillMaxWidth() @@ -598,7 +606,7 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware + val showTagAction = true if (showContactActions || showTagAction) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -660,12 +668,11 @@ private fun ActivityDetailContent( val hasCompletedBoost = when (item) { is Activity.Lightning -> false is Activity.Onchain -> { - val activity = item.v1 - if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { - if (activity.txType == PaymentType.SENT) { + if (item.isBoosted() && item.v1.boostTxIds.isNotEmpty()) { + if (item.txType() == PaymentType.SENT) { true } else { - activity.boostTxIds.any { boostTxDoesExist[it] == true } + item.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } else { false @@ -705,7 +712,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, + onClick = { onExploreClick(item.rawId(), item.walletId()) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), @@ -851,7 +858,7 @@ private fun StatusSection( Row(verticalAlignment = Alignment.CenterVertically) { when (item) { is Activity.Lightning -> { - when (item.v1.status) { + when (item.paymentState()) { PaymentState.PENDING -> { StatusRow( painterResource(R.drawable.ic_hourglass_simple), @@ -875,6 +882,8 @@ private fun StatusSection( Colors.Purple, ) } + + null -> Unit } } @@ -885,29 +894,29 @@ private fun StatusSection( var statusText = stringResource(R.string.wallet__activity_confirming) var statusTestTag: String? = null - if (item.v1.isTransfer) { + if (item.isTransfer()) { val context = LocalContext.current - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" } - if (item.v1.isBoosted) { + if (item.isBoosted()) { statusIcon = painterResource(R.drawable.ic_timer_alt) statusColor = Colors.Yellow statusText = stringResource(R.string.wallet__activity_boosting) statusTestTag = "StatusBoosting" } - if (item.v1.confirmed) { + if (item.confirmed() == true) { statusIcon = painterResource(R.drawable.ic_check_circle) statusColor = Colors.Green statusText = stringResource(R.string.wallet__activity_confirmed) statusTestTag = "StatusConfirmed" } - if (!item.v1.doesExist) { + if (!item.doesExist()) { statusIcon = painterResource(R.drawable.ic_x) statusColor = Colors.Red statusText = stringResource(R.string.wallet__activity_removed) @@ -980,25 +989,14 @@ private fun ZigzagDivider() { private fun PreviewLightningSent() { AppThemeSurface { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {} @@ -1011,27 +1009,14 @@ private fun PreviewLightningSent() { private fun PreviewOnchain() { AppThemeSurface { ActivityDetailContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ), + item = previewOnchainDetailItem(), assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1047,25 +1032,14 @@ private fun PreviewSheetSmallScreen() { modifier = Modifier.sheetHeight(), ) { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1085,29 +1059,61 @@ private fun shouldEnableBoostButton( if (isHardware) return false if (item !is Activity.Onchain) return false - val activity = item.v1 - // Check all disable conditions - val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || - (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) + val shouldDisable = isCpfpChild || !item.doesExist() || item.confirmed() == true || + (item.isBoosted() && isBoostCompleted(item, boostTxDoesExist)) if (shouldDisable) return false // Enable if not a transfer and has value - return !activity.isTransfer && activity.value > 0uL + return !item.isTransfer() && item.value() > 0uL } @ReadOnlyComposable @Composable private fun isBoostCompleted( - activity: OnchainActivity, + activity: Activity.Onchain, boostTxDoesExist: ImmutableMap, ): Boolean { - if (activity.boostTxIds.isEmpty()) return true + if (activity.v1.boostTxIds.isEmpty()) return true - if (activity.txType == PaymentType.SENT) { + if (activity.txType() == PaymentType.SENT) { return true } else { - return activity.boostTxIds.any { boostTxDoesExist[it] == true } + return activity.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} 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..8985407117 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 @@ -46,6 +46,7 @@ import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isSent import to.bitkit.ext.totalValue +import to.bitkit.ext.txType import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -75,8 +76,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 @@ -303,8 +304,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.inputs.forEach { input -> - val text = "${input.txid}:${input.vout}" - BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = "${input.txid}:${input.vout}", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -317,8 +321,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.outputs.forEach { output -> - val address = output.scriptpubkeyAddress ?: "" - BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = output.scriptpubkeyAddress ?: "", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -339,7 +346,7 @@ private fun ColumnScope.OnchainDetails( val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { boostTxIds.forEachIndexed { index, boostedTxId -> - val isRbf = onchain.v1.txType == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) + val isRbf = onchain.txType() == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp @@ -389,19 +396,7 @@ private fun Section( private fun PreviewLightning() { AppThemeSurface { ActivityExploreContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ), - ), + item = previewLightningDetailItem(), ) } } @@ -411,20 +406,42 @@ private fun PreviewLightning() { private fun PreviewOnchain() { AppThemeSurface { ActivityExploreContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ), - ), + item = previewOnchainDetailItem(), ) } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} 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..a596ab12a0 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: (String, String) -> 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: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() @@ -167,7 +167,7 @@ private fun Preview() { onBackClick = {}, onTagClick = {}, onDateRangeClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onRemoveTag = {}, onEmptyActivityRowClick = {}, ) @@ -192,7 +192,7 @@ private fun PreviewEmpty() { onTagClick = {}, onDateRangeClick = {}, onRemoveTag = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 1127c8c768..f0f1af3495 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -19,12 +19,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.LightningActivity -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.create import to.bitkit.ext.doesExist import to.bitkit.ext.isBoosting import to.bitkit.ext.isTransfer @@ -32,6 +29,7 @@ import to.bitkit.ext.paymentState import to.bitkit.ext.txType import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -44,14 +42,17 @@ fun ActivityIcon( isHardware: Boolean = false, contact: PubkyProfile? = null, ) { - val isLightning = activity is Activity.Lightning - val isBoosting = activity.isBoosting() - val status = activity.paymentState() val txType = activity.txType() - val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) + val arrowIcon = painterResource( + id = if (txType == PaymentType.SENT) { + R.drawable.ic_sent + } else { + R.drawable.ic_received + }, + ) when { - isCpfpChild || isBoosting -> { + isCpfpChild || activity.isBoosting() -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -67,21 +68,30 @@ fun ActivityIcon( testTag = "ActivityContactAvatar", modifier = modifier ) - isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) - else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) + activity is Activity.Lightning -> ActivityIconLightning(activity.paymentState(), size, arrowIcon, modifier) + else -> ActivityIconOnchain( + txType = txType, + isTransfer = activity.isTransfer(), + doesExist = activity.doesExist(), + arrowIcon = arrowIcon, + size = size, + isHardware = isHardware, + modifier = modifier, + ) } } @Composable private fun ActivityIconOnchain( - activity: Activity, + txType: PaymentType, + isTransfer: Boolean, + doesExist: Boolean, arrowIcon: Painter, size: Dp, isHardware: Boolean, modifier: Modifier = Modifier, ) { - val isTransfer = activity.isTransfer() - val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED + val isTransferFromSpending = isTransfer && txType == PaymentType.RECEIVED val (iconColor, backgroundColor) = when { isHardware -> Colors.Blue to Colors.Blue16 isTransferFromSpending -> Colors.Purple to Colors.Purple16 @@ -95,7 +105,7 @@ private fun ActivityIconOnchain( CircularIcon( icon = when { - !activity.doesExist() -> painterResource(R.drawable.ic_x) + !doesExist -> painterResource(R.drawable.ic_x) isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, @@ -177,145 +187,7 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - // Lightning Sent Succeeded - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Received Failed - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-2", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Pending - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-3", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Onchain Received - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST CPFP - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST RBF - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain Transfer - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isTransfer = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) - - // Onchain Removed - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isBoosted = true, - doesExist = false, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) + previewActivityItems.forEach { ActivityIcon(activity = it) } } } } 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..707eed602a 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,7 +29,9 @@ 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.activityKey +import to.bitkit.ext.groupTimestamp +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +50,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -78,22 +81,17 @@ fun ActivityListGrouped( ) { itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } } ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -106,7 +104,8 @@ fun ActivityListGrouped( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -116,12 +115,12 @@ fun ActivityListGrouped( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", - title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, - contact = if (showContactAvatar) contactForActivity(item, contacts) else null, + title = titleProvider(activity) ?: contactActivityTitle(activity, contacts), + isHardware = activity.scopedId() in hardwareIds, + contact = if (showContactAvatar) contactForActivity(activity, contacts) else null, ) VerticalSpacer(16.dp) } @@ -165,7 +164,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -176,22 +175,17 @@ fun LazyListScope.activityListGroupedItems( val groupedItems = groupActivityItems(items) itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } }, ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -204,7 +198,8 @@ fun LazyListScope.activityListGroupedItems( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -214,10 +209,10 @@ fun LazyListScope.activityListGroupedItems( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = activity.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } @@ -264,7 +259,7 @@ fun LazyListScope.activityListGroupedItems( // region utils @Suppress("CyclomaticComplexMethod") -private fun groupActivityItems(activityItems: List): List { +private fun groupActivityItems(activityItems: List): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -284,10 +279,7 @@ private fun groupActivityItems(activityItems: List): List { val earlierItems = mutableListOf() for (item in activityItems) { - val timestamp = when (item) { - is Activity.Lightning -> item.v1.timestamp.toLong() - is Activity.Onchain -> item.v1.timestamp.toLong() - } + val timestamp = item.groupTimestamp().toLong() when { timestamp >= startOfDay -> todayItems.add(item) timestamp >= startOfYesterday -> yesterdayItems.add(item) @@ -300,33 +292,42 @@ private fun groupActivityItems(activityItems: List): List { return buildList { if (todayItems.isNotEmpty()) { - add("TODAY") - addAll(todayItems) + add(ActivityGroupHeader("TODAY")) + addAll(todayItems.map { ActivityGroupEntry(it) }) } if (yesterdayItems.isNotEmpty()) { - add("YESTERDAY") - addAll(yesterdayItems) + add(ActivityGroupHeader("YESTERDAY")) + addAll(yesterdayItems.map { ActivityGroupEntry(it) }) } if (weekItems.isNotEmpty()) { - add("THIS WEEK") - addAll(weekItems) + add(ActivityGroupHeader("THIS WEEK")) + addAll(weekItems.map { ActivityGroupEntry(it) }) } if (monthItems.isNotEmpty()) { - add("THIS MONTH") - addAll(monthItems) + add(ActivityGroupHeader("THIS MONTH")) + addAll(monthItems.map { ActivityGroupEntry(it) }) } if (yearItems.isNotEmpty()) { - add("THIS YEAR") - addAll(yearItems) + add(ActivityGroupHeader("THIS YEAR")) + addAll(yearItems.map { ActivityGroupEntry(it) }) } if (earlierItems.isNotEmpty()) { - add("EARLIER") - addAll(earlierItems) + add(ActivityGroupHeader("EARLIER")) + addAll(earlierItems.map { ActivityGroupEntry(it) }) } } } // endregion +@Immutable +private sealed interface GroupedActivityItem + +@Immutable +private data class ActivityGroupHeader(val title: String) : GroupedActivityItem + +@Immutable +private data class ActivityGroupEntry(val item: Activity) : GroupedActivityItem + @Preview @Composable private fun Preview() { @@ -334,7 +335,7 @@ private fun Preview() { Column(modifier = Modifier.padding(horizontal = 16.dp)) { ActivityListGrouped( items = previewActivityItems, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -347,7 +348,7 @@ private fun PreviewEmpty() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -359,7 +360,7 @@ private fun PreviewEmptyWithFooter() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, showFooter = true, ) 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..b20bb4c06b 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: (String, String) -> 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) { @@ -76,7 +76,7 @@ private fun Preview() { ActivityListSimple( items = previewActivityItems, onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -88,7 +88,7 @@ private fun PreviewEmpty() { ActivityListSimple( items = persistentListOf(), onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } 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..501732239d 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 @@ -29,13 +29,20 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.DatePattern +import to.bitkit.ext.confirmed +import to.bitkit.ext.doesExist +import to.bitkit.ext.feeRate import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue +import to.bitkit.ext.txId import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -65,7 +72,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (String, String) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -76,20 +83,15 @@ fun ActivityRow( } val feeRates = blocktankInfo?.onchain?.feeRates - val status: PaymentState? = when (item) { - is Activity.Lightning -> item.v1.status - is Activity.Onchain -> null - } + val status = item.paymentState() val isLightning = item is Activity.Lightning val timestamp = item.timestamp() - val txType: PaymentType = item.txType() + val txType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" - val confirmed: Boolean? = when (item) { - is Activity.Lightning -> null - is Activity.Onchain -> item.v1.confirmed - } + val confirmed = item.confirmed() val isTransfer = item.isTransfer() + val txId = item.txId() val activityListViewModel = activityListViewModel var isCpfpChild by remember { mutableStateOf(false) } val resolvedTitle = title.takeIf { @@ -99,9 +101,9 @@ fun ActivityRow( shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } - LaunchedEffect(item) { - isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(item.v1.txId) + LaunchedEffect(txId) { + isCpfpChild = if (txId != null && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(txId) } else { false } @@ -111,7 +113,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item.rawId(), item.walletId()) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) @@ -137,37 +139,36 @@ fun ActivityRow( title = resolvedTitle, ) val context = LocalContext.current - val subtitleText = when (item) { - is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } - is Activity.Onchain -> { - when { - !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) + val subtitleText = if (isLightning) { + item.message().ifEmpty { formattedTime(timestamp) } + } else { + when { + !item.doesExist() -> stringResource(R.string.wallet__activity_removed) - isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) - isTransfer && isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_spending_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", duration) - } + isTransfer && isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_spending_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_spending_pending) + .replace("{duration}", duration) + } - isTransfer && !isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_savings_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", duration) - } + isTransfer && !isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_savings_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", duration) + } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> formattedTime(timestamp) - else -> { - val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in) - .replace("{feeRateDescription}", feeDescription) - } + else -> { + val feeDescription = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -193,9 +194,9 @@ private fun shouldUseContactActivityTitle( ): Boolean { if (isTransfer || isCpfpChild) return false - return when (activity) { - is Activity.Lightning -> status == PaymentState.SUCCEEDED - is Activity.Onchain -> activity.v1.doesExist + return when { + activity is Activity.Lightning -> status == PaymentState.SUCCEEDED + else -> activity.doesExist() } } @@ -387,7 +388,7 @@ private fun Preview(@PreviewParameter(ActivityItemsPreviewProvider::class) item: AppThemeSurface { ActivityRow( item = item, - onClick = {}, + onClick = { _, _ -> }, testTag = "Activity-", ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt index c74fb3c9a0..3fce59762f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import java.util.Calendar val previewActivityItems: ImmutableList = buildList { @@ -19,97 +20,119 @@ val previewActivityItems: ImmutableList = buildList { fun Calendar.epochSecond() = (timeInMillis / 1000).toULong() - // Today add( - Activity.Onchain( - OnchainActivity.create( - id = "1", - txType = PaymentType.RECEIVED, - txId = "01", - value = 42_000_u, - fee = 200_u, - address = "bc1", - confirmed = true, - timestamp = today.epochSecond(), - isBoosted = true, - boostTxIds = listOf("02", "03"), - doesExist = false, - confirmTimestamp = today.epochSecond(), - channelId = "channelId", - transferTxId = "transferTxId", - createdAt = today.epochSecond() - 30_000u, - updatedAt = today.epochSecond(), - ) + onchainPreviewItem( + id = "1", + txType = PaymentType.RECEIVED, + value = 42_000uL, + fee = 200uL, + timestamp = today.epochSecond(), + txId = "01", + confirmed = true, + isBoosted = true, + doesExist = false, ) ) - // Yesterday add( - Activity.Lightning( - LightningActivity.create( - id = "2", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 30_000_u, - invoice = "lnbc2", - timestamp = yesterday.epochSecond(), - fee = 15_u, - message = "Custom very long lightning activity message to test truncation", - preimage = "preimage1", - ) + lightningPreviewItem( + id = "2", + txType = PaymentType.SENT, + status = PaymentState.PENDING, + value = 30_000uL, + fee = 15uL, + timestamp = yesterday.epochSecond(), + message = "Custom very long lightning activity message to test truncation", ) ) - // This Week add( - Activity.Lightning( - LightningActivity.create( - id = "3", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 217_000_u, - invoice = "lnbc3", - timestamp = thisWeek.epochSecond(), - fee = 17_u, - preimage = "preimage2", - ) + lightningPreviewItem( + id = "3", + txType = PaymentType.RECEIVED, + status = PaymentState.FAILED, + value = 217_000uL, + fee = 17uL, + timestamp = thisWeek.epochSecond(), ) ) - // This Month add( - Activity.Onchain( - OnchainActivity.create( - id = "4", - txType = PaymentType.SENT, - txId = "04", - value = 950_000_u, - fee = 110_u, - address = "bc1", - timestamp = thisMonth.epochSecond(), - isTransfer = true, - confirmTimestamp = today.epochSecond() + 3600u, - channelId = "channelId", - transferTxId = "transferTxId", - ) + onchainPreviewItem( + id = "4", + txType = PaymentType.SENT, + value = 950_000uL, + fee = 110uL, + timestamp = thisMonth.epochSecond(), + txId = "04", + isTransfer = true, ) ) - // Last Year add( - Activity.Lightning( - LightningActivity.create( - id = "5", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 200_000_u, - invoice = "lnbc…", - timestamp = lastYear.epochSecond(), - fee = 1_u, - ) + lightningPreviewItem( + id = "5", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 200_000uL, + fee = 1uL, + timestamp = lastYear.epochSecond(), ) ) }.toImmutableList() fun previewOnchainActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + fun previewLightningActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + +@Suppress("LongParameterList") +private fun lightningPreviewItem( + id: String, + txType: PaymentType, + status: PaymentState, + value: ULong, + fee: ULong, + timestamp: ULong, + message: String = "", +) = Activity.Lightning( + LightningActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + status = status, + value = value, + fee = fee, + invoice = "lnbc$id", + message = message, + timestamp = timestamp, + ) +) + +@Suppress("LongParameterList") +private fun onchainPreviewItem( + id: String, + txType: PaymentType, + value: ULong, + fee: ULong, + timestamp: ULong, + txId: String, + confirmed: Boolean = false, + isBoosted: Boolean = false, + isTransfer: Boolean = false, + doesExist: Boolean = true, +) = Activity.Onchain( + OnchainActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + value = value, + fee = fee, + txId = txId, + address = "bc1qpreview$id", + timestamp = timestamp, + confirmed = confirmed, + isBoosted = isBoosted, + isTransfer = isTransfer, + doesExist = doesExist, + ) +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index ffd725a7fe..b39498f401 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -37,7 +37,6 @@ import to.bitkit.R import to.bitkit.ext.uniqueUtxoKey import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview @@ -67,11 +66,7 @@ fun SendCoinSelectionScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val tagsByTxId by viewModel.tagsByTxId.collectAsStateWithLifecycle() - val activity = activityListViewModel ?: return - val onchainActivities by activity.onchainActivities.collectAsStateWithLifecycle() - - LaunchedEffect(requiredAmount, onchainActivities) { - viewModel.setOnchainActivities(onchainActivities.orEmpty()) + LaunchedEffect(requiredAmount, address) { viewModel.loadUtxos(requiredAmount, address) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 0d771fb139..0fbebd8249 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -3,8 +3,6 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.Activity.Onchain import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -20,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults -import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -43,12 +40,6 @@ class SendCoinSelectionViewModel @Inject constructor( private val _tagsByTxId = MutableStateFlow>>(persistentMapOf()) val tagsByTxId = _tagsByTxId.asStateFlow() - private var onchainActivities: List = emptyList() - - fun setOnchainActivities(onchainActivities: List) { - this.onchainActivities = onchainActivities - } - fun loadUtxos(requiredAmount: ULong, address: String) = viewModelScope.launch { runCatching { val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() @@ -82,13 +73,10 @@ class SendCoinSelectionViewModel @Inject constructor( if (_tagsByTxId.value.containsKey(txId)) return viewModelScope.launch(bgDispatcher) { - // find activity by txId - onchainActivities.firstOrNull { (it as? Onchain)?.v1?.txId == txId }?.let { activity -> - // get tags by activity id - activityRepo.getActivityTags(activity.rawId()) + activityRepo.getOnchainActivityByTxId(txId)?.let { activity -> + activityRepo.getActivityTags(activity.id) .onSuccess { tags -> if (tags.isNotEmpty()) { - // add map entry linking tags to utxo.outpoint.txid _tagsByTxId.update { (it + (txId to tags.toImmutableList())).toImmutableMap() } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index c9fe07801d..199c6e6ecf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.Activity import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.ui.components.BodyMSB @@ -64,13 +63,14 @@ fun BoostTransactionSheet( onMaxFee: () -> Unit, onMinFee: () -> Unit, onDismiss: () -> Unit, - item: Activity.Onchain, + activityId: String, + walletId: String, ) { val haptic = LocalHapticFeedback.current // Setup activity when component is first created - LaunchedEffect(Unit) { - viewModel.setupActivity(item) + LaunchedEffect(activityId, walletId) { + viewModel.setupActivity(activityId, walletId) } // Handle effects diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index e814bb10fa..fe22c138c2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -57,7 +57,27 @@ class BoostTransactionViewModel @Inject constructor( private var minFeeRate: ULong = 2U private var activity: Activity.Onchain? = null - fun setupActivity(activity: Activity.Onchain) { + fun setupActivity(activityId: String, walletId: String?) { + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + activityRepo.getActivity(activityId, walletId) + .onSuccess { + val activity = it as? Activity.Onchain + if (activity == null) { + handleError("Activity '$activityId' is not boostable") + return@onSuccess + } + + setupActivity(activity) + } + .onFailure { + handleError("Failed to load activity '$activityId'", it) + } + } + } + + private fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) this.activity = activity diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..f199dce0e6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -25,9 +25,10 @@ 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.models.USat 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 +41,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 +58,31 @@ 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)) } + _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 +97,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 +123,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 +145,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 +188,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 } } @@ -273,6 +251,17 @@ class ActivityDetailViewModel @Inject constructor( }.getOrNull() } + suspend fun findTransferOrderAmounts( + channelId: String?, + txId: String?, + ): TransferOrderAmounts? { + val order = findOrderForTransfer(channelId, txId) ?: return null + return TransferOrderAmounts( + serviceFee = USat(order.feeSat) - USat(order.clientBalanceSat), + transferAmount = order.clientBalanceSat, + ) + } + private companion object { const val TAG = "ActivityDetailViewModel" } @@ -286,6 +275,10 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, - val isHardwareActivity: Boolean = false, ) } + +data class TransferOrderAmounts( + val serviceFee: ULong, + val transferAmount: ULong, +) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index df932631a0..e5239801e8 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() { @@ -60,25 +59,9 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() 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 +74,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 +118,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 aa0d1ba2bf..ba38aafbb7 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 @@ -334,6 +335,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2441,8 +2443,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 } @@ -2459,7 +2462,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) @@ -2483,7 +2486,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideSheet() _successSendUiState.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 38116c390e..345aefd935 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountOnClose import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction @@ -250,6 +251,7 @@ class TransferViewModel @Inject constructor( createTransferActivity: Boolean = false, fee: ULong = 0uL, feeRate: ULong = 0uL, + walletId: String = DEFAULT_WALLET_ID, ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) transferRepo.createTransfer( @@ -264,6 +266,7 @@ class TransferViewModel @Inject constructor( txId = txId, fee = fee, feeRate = feeRate, + walletId = walletId, ) } viewModelScope.launch { walletRepo.syncBalances() } @@ -505,6 +508,10 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) return@launch } + val walletId = hwWalletRepo.getWalletId(deviceId).getOrElse { + handleHardwareTransferFailure(it, deviceId) + return@launch + } signTransferToSpendingWithHardware(order, deviceId, address) .onSuccess { result -> @@ -514,6 +521,7 @@ class TransferViewModel @Inject constructor( createTransferActivity = true, fee = result.miningFeeSats, feeRate = result.feeRate, + walletId = walletId, ) setTransferEffect(TransferEffect.OnHwTxSigned) } 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 1c935765f6..2402f8bd3a 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..7c27455b6f 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,39 @@ 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 - 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)) - - 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) + 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) + val activity = loadState.activity + assertTrue(activity is Activity.Onchain) + assertEquals(ACTIVITY_ID, activity.v1.id) + assertEquals(hardwareWalletId, activity.v1.walletId) + + sut.addTag("tag1") + + 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 +170,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) @@ -190,16 +179,20 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Verify initial state loaded val initialState = sut.uiState.value.activityLoadState assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) + assertTrue(initialState.activity is Activity.Onchain) + assertEquals(initialActivity.v1.id, initialState.activity.v1.id) + assertEquals(initialActivity.v1.confirmed, initialState.activity.v1.confirmed) // 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 val updatedState = sut.uiState.value.activityLoadState assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) + assertTrue(updatedState.activity is Activity.Onchain) + assertEquals(updatedActivity.v1.id, updatedState.activity.v1.id) + assertEquals(updatedActivity.v1.confirmed, updatedState.activity.v1.confirmed) } @Test @@ -208,8 +201,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,25 +225,29 @@ 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 val state = sut.uiState.value.activityLoadState assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(activity, state.activity) + assertTrue(state.activity is Activity.Onchain) + assertEquals(activity.v1.id, state.activity.v1.id) + assertEquals(activity.v1.confirmed, state.activity.v1.confirmed) } @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..a87c132e17 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", @@ -77,6 +79,7 @@ class ActivityRepoTest : BaseUnitTest() { @Suppress("LongParameterList") private fun createOnchainActivity( id: String = baseOnchainActivity.id, + txType: PaymentType = baseOnchainActivity.txType, txId: String = baseOnchainActivity.txId, value: ULong = baseOnchainActivity.value, fee: ULong = baseOnchainActivity.fee, @@ -94,10 +97,12 @@ class ActivityRepoTest : BaseUnitTest() { contact: String? = baseOnchainActivity.contact, createdAt: ULong? = baseOnchainActivity.createdAt, updatedAt: ULong? = baseOnchainActivity.updatedAt, + walletId: String = baseOnchainActivity.walletId, ): Activity.Onchain { return Activity.Onchain( v1 = baseOnchainActivity.copy( id = id, + txType = txType, txId = txId, value = value, fee = fee, @@ -114,11 +119,20 @@ class ActivityRepoTest : BaseUnitTest() { transferTxId = transferTxId, contact = contact, createdAt = createdAt, - updatedAt = updatedAt + updatedAt = updatedAt, + walletId = walletId, ) ) } + private fun transactionDetails(txId: String, amountSats: Long) = BitkitCoreTransactionDetails( + walletId = hardwareWalletId, + txId = txId, + amountSats = amountSats, + inputs = emptyList(), + outputs = emptyList(), + ) + @Before fun setUp() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) @@ -159,7 +173,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 +210,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -254,7 +269,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 +278,137 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { + fun `getActivity passes wallet id to core lookup`() = test { + val activityId = "activity123" + wheneverBlocking { coreService.activity.getActivity(activityId, hardwareWalletId) }.thenReturn(testActivity) + + val result = sut.getActivity(activityId, hardwareWalletId) + + assertTrue(result.isSuccess) + assertEquals(testActivity, result.getOrThrow()) + verify(coreService.activity).getActivity(activityId, hardwareWalletId) + } + + @Test + fun `persistHardwareActivities upserts multiple activities and transaction details`() = test { + val activities = listOf( + createOnchainActivity( + id = "hw-received-id", + txId = "hw-received-txid", + value = 10_000uL, + fee = 0uL, + timestamp = 2_000uL, + confirmed = true, + walletId = hardwareWalletId, + ), + createOnchainActivity( + id = "hw-sent-id", + txId = "hw-sent-txid", + value = 4_200uL, + fee = 321uL, + timestamp = 3_000uL, + confirmed = false, + walletId = hardwareWalletId, + txType = PaymentType.SENT, + ), + createOnchainActivity( + id = "hw-transfer-id", + txId = "hw-transfer-txid", + value = 7_000uL, + fee = 222uL, + timestamp = 4_000uL, + confirmed = true, + isTransfer = true, + walletId = hardwareWalletId, + txType = PaymentType.SENT, + ), + ) + val details = listOf( + transactionDetails("hw-received-txid", 10_000L), + transactionDetails("hw-sent-txid", -4_521L), + transactionDetails("hw-transfer-txid", -7_222L), + ) + wheneverBlocking { coreService.activity.upsertList(activities) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.upsertTransactionDetailsList(details) }.thenReturn(Unit) + + val result = sut.persistHardwareActivities(activities, details) + + assertTrue(result.isSuccess) + verify(coreService.activity).upsertList(activities) + verify(coreService.activity).upsertTransactionDetailsList(details) + } + + @Test + fun `persistHardwareActivities preserves transfer metadata for existing hardware activity`() = test { + val incoming = createOnchainActivity( + id = "hw-transfer-txid", + txId = "hw-transfer-txid", + value = 7_000uL, + fee = 222uL, + timestamp = 4_000uL, + confirmed = true, + isTransfer = false, + walletId = hardwareWalletId, + txType = PaymentType.SENT, + ) val existing = createOnchainActivity( - id = "transfer-txid", - txId = "transfer-txid", - value = 50_000uL, - fee = 0uL, - feeRate = 2uL, - address = "bc1qlsp", + id = "hw-transfer-txid", + txId = "hw-transfer-txid", + value = 7_000uL, + fee = 222uL, + timestamp = 3_900uL, 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", + channelId = "channel-id", + walletId = hardwareWalletId, 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) + val expected = Activity.Onchain( + incoming.v1.copy( + isTransfer = true, + channelId = "channel-id", + ) + ) + whenever { + coreService.activity.getOnchainActivityByTxId("hw-transfer-txid", hardwareWalletId) + }.thenReturn(existing.v1) + whenever { coreService.activity.upsertList(listOf(expected)) }.thenReturn(Unit) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardwareActivities(listOf(incoming), emptyList()) assertTrue(result.isSuccess) - val captor = argumentCaptor() - verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) - val updated = (captor.firstValue as Activity.Onchain).v1 - assertTrue(updated.confirmed) - assertEquals(2_000uL, updated.confirmTimestamp) - assertEquals(true, updated.doesExist) - assertEquals(50_000uL, updated.value) - assertEquals(1_250uL, updated.fee) - assertEquals(2uL, updated.feeRate) - assertEquals("bc1qlsp", updated.address) - assertEquals(true, updated.isTransfer) - assertEquals("channel-1", updated.channelId) - assertEquals(true, updated.isBoosted) - assertEquals(listOf("boost-txid"), updated.boostTxIds) - assertEquals("contact", updated.contact) - } - - @Test - fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create( - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, - confirmed = true, - ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + verify(coreService.activity).upsertList(listOf(expected)) + } - val result = sut.syncHardwareOnchainActivity(watcher) + @Test + fun `persistHardwareActivities does nothing when both lists are empty`() = test { + val result = sut.persistHardwareActivities(emptyList(), emptyList()) 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, 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 +563,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 +696,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 +838,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +891,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +944,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +996,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +1049,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..ecb01678a5 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 @@ -32,6 +30,8 @@ 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 +42,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 +54,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 +70,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = trezorWalletId, ) @Before @@ -83,10 +83,12 @@ 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)) + whenever(trezorRepo.deriveWalletId(any())).thenReturn(trezorWalletId) + whenever { + trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + }.thenReturn(Result.success(Unit)) + whenever { activityRepo.persistHardwareActivities(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { 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,160 +140,128 @@ 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 matching wallet entries`() = test { val sut = createRepo() + val received = onchainActivity(txid = "t1", amount = 10_562_411uL) + val sent = onchainActivity(txid = "t2", amount = 125_000uL, txType = PaymentType.SENT) + val otherWallet = onchainActivity( + txid = "other-wallet-tx", + amount = 7_000uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev2"), + ) + val details = listOf( + transactionDetails(txid = "t1", amountSats = 10_562_411L), + transactionDetails(txid = "t2", amountSats = -125_000L), + ) + val otherWalletDetails = transactionDetails( + txid = "other-wallet-tx", + amountSats = 7_000L, + walletId = ActivityWalletType.TREZOR.scopedId("dev2"), + ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 10_562_411uL), - transactions = listOf(receivedTransaction(amount = 10_562_411uL)), - txCount = 1u, - blockHeight = 850_000u, - accountType = AccountType.NATIVE_SEGWIT, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(received, sent, otherWallet), + transactionDetails = details + otherWalletDetails, + balanceTotal = 10_562_411uL, + txCount = 3u, ) ) val wallet = sut.wallets.value.single() 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(listOf("t1", "t2"), wallet.activities.map { (it as Activity.Onchain).v1.txId }) + verify(activityRepo).persistHardwareActivities(listOf(received, sent), details) } @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 updates balance and activities when persistence fails`() = test { + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) + whenever { 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(10_562_411uL, sut.totalSats.value) + assertEquals(listOf(activity), 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 +282,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 +300,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 +326,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 +341,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 +365,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 +404,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 +445,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 +468,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 +525,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 +548,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 +559,18 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `removeDevice stops the device watchers and forgets it`() = test { + fun `getWalletId returns stored hardware wallet id`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val sut = createRepo() + + val result = sut.getWalletId("dev1") + + assertTrue(result.isSuccess) + assertEquals(trezorWalletId, result.getOrThrow()) + } + + @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 +583,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 +674,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 +858,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 +945,63 @@ 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, + ) + ) + + private fun transactionDetails( + txid: String, + amountSats: Long, + walletId: String = trezorWalletId, + ) = BitkitCoreTransactionDetails( + walletId = walletId, + txId = txid, + amountSats = amountSats, + inputs = emptyList(), + outputs = emptyList(), + ) + + @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 +1009,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 039f52c708..16f64eaa7b 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -32,6 +32,7 @@ import to.bitkit.models.TransferType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -58,6 +59,7 @@ class TransferRepoTest : BaseUnitTest() { private const val ID_ORDER = "test-order-id" private const val ID_CHANNEL = "test-channel-id" private const val ID_TRANSFER = "test-transfer-id" + private const val ID_HW_WALLET = "trezor:dev1" private val fundingTxo = OutPoint(txid = "test-funding-tx-id", vout = 0u) } @@ -147,6 +149,33 @@ class TransferRepoTest : BaseUnitTest() { verify(transferDao).insert(any()) } + @Test + fun `createPendingToSpendingActivity passes hardware wallet id`() = test { + val order = previewBtOrder() + val fee = 123uL + val feeRate = 2uL + + val result = sut.createPendingToSpendingActivity( + order = order, + txId = fundingTxo.txid, + fee = fee, + feeRate = feeRate, + walletId = ID_HW_WALLET, + ) + + assertTrue(result.isSuccess) + verify(activityService).createSentOnchainActivityFromSendResult( + eq(fundingTxo.txid), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(fee), + eq(feeRate), + eq(true), + eq(order.channel?.shortChannelId), + eq(ID_HW_WALLET), + ) + } + @Test fun `createTransfer handles database insertion failure`() = test { setupClockNowMock() @@ -643,6 +672,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..622a7399ce 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 @@ -66,6 +66,24 @@ class TrezorRepoTest : BaseUnitTest() { private const val TEST_MESSAGE = "Hello Trezor" private const val TEST_SIGNATURE = "signature123" private const val TEST_ADDRESS = "bc1qtest" + private const val TEST_UPUB = + "upub5DWPhYKrgLiETEmgLykymFzBK6gXUNvkE67HLSEh5zcWNRdx2Hxd8HypxaDa" + + "K1p62a1kXwe9eBcV3pGm7yEpHh4ebrspSoer4x8Ko29egtv" + private const val TEST_VPUB = + "vpub5ZgC33hDPuhHSDYrGeoi685MKtdAUB3pkS4rarJ5dv3RABneKLCbh" + + "Ju2FNfcajo1GukBAwEMBXZWvU7fykTeyKEJrgV6E6NC7BZ3jzp3ffp" + private const val TEST_LEGACY_TPUB = + "tpubDDKn3FtHc74CaRrRbi1WFdJNaaenZkDWqq9NsEhcafnDZ4VuKeuLG2aKHm5Suwu" + + "LgAhRkkfHqcCxpnVNSrs5kJYZXwa6Ud431VnevzzzK3U" + private const val TEST_TPUB = + "tpubDCNp8nAHqfJ8GvXtJF6wbSc4A8DXHdQJX4GF3AyVZvW3H1kLrVJQ8vd5GEB" + + "7qEj63Pu5PwwhZGWBPrc4fvL6ZqZkGUiPt4mYU5ufinGjwNn" + private const val TEST_VPUB_TPUB = + "tpubDDiMAcrjQYjhQc7wPZN3iDbizx1iLoXt8HhbWC8zjqZ51g5otsNp5st8Xpfa" + + "7445t6WhHTwLdx6fPDqQp18vTE3oexdF5SfQEbGkHEHmuvi" + private const val TEST_TAPROOT_TPUB = + "tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBk" + + "UpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN" } @get:Rule(order = 1) @@ -95,13 +113,16 @@ 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) whenever(settingsStore.data).thenReturn(settingsData) whenever(context.filesDir).thenReturn(tempFolder.root) whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) + whenever(trezorService.deriveWalletId(any(), any())).thenAnswer { + walletIdFor(*it.getArgument>(1).toTypedArray()) + } } private fun createSut(): TrezorRepo = TrezorRepo( @@ -115,6 +136,9 @@ class TrezorRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, ) + private fun walletIdFor(vararg xpubs: String): String = + ActivityWalletType.TREZOR.scopedId(xpubs.sorted().joinToString("|")) + @Suppress("LongParameterList") private fun mockDeviceInfo( id: String = DEVICE_ID, @@ -195,8 +219,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 +231,42 @@ 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(walletIdFor("zpubNS"), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id, listOf("zpubNS")) assertEquals(listOf(saved), sut.state.value.knownDevices) } + @Test + fun `initialize normalizes restored slip132 xpubs`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = mapOf("nativeSegwit" to TEST_UPUB)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + val savedCaptor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) + val saved = savedCaptor.firstValue.single() + assertEquals(TEST_TPUB, saved.xpubs["nativeSegwit"]) + assertEquals(walletIdFor(TEST_TPUB), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id, listOf(TEST_TPUB)) + } + + @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) + assertEquals("", sut.state.value.knownDevices.single().walletId) + verify(hwWalletStore, never()).saveKnownDevices(any()) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -578,7 +634,86 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) - assertNotNull(UUID.fromString(saved.walletId)) + 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(walletIdFor("captured-native-xpub"), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id, listOf("captured-native-xpub")) + } + + @Test + fun `connect persists normalized xpubs by address type path`() = test { + val legacyPath = "m/44'/1'/0'" + val nestedSegwitPath = "m/49'/1'/0'" + val nativeSegwitPath = "m/84'/1'/0'" + val taprootPath = "m/86'/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) + when (path) { + legacyPath -> mockPublicKeyResponse(xpub = TEST_LEGACY_TPUB, path = path) + nestedSegwitPath -> mockPublicKeyResponse(xpub = TEST_UPUB, path = path) + nativeSegwitPath -> mockPublicKeyResponse(xpub = TEST_VPUB, path = path) + taprootPath -> mockPublicKeyResponse(xpub = TEST_TAPROOT_TPUB, path = path) + 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(TEST_LEGACY_TPUB, saved.xpubs["legacy"]) + assertEquals(TEST_TPUB, saved.xpubs["nestedSegwit"]) + assertEquals(TEST_VPUB_TPUB, saved.xpubs["nativeSegwit"]) + assertEquals(TEST_TAPROOT_TPUB, saved.xpubs["taproot"]) + verify(trezorService).deriveWalletId( + ActivityWalletType.TREZOR.id, + listOf(TEST_LEGACY_TPUB, TEST_TPUB, TEST_VPUB_TPUB, TEST_TAPROOT_TPUB), + ) } @Test @@ -622,6 +757,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 = walletIdFor("old-native-xpub") + val newWalletId = walletIdFor("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( @@ -1083,6 +1259,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) + verify(hwWalletStore).saveKnownDevices(any()) } @Test @@ -1126,6 +1303,31 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService).connect(eq(bleDeviceId), any()) } + @Test + fun `ensureConnected should reconnect without refreshing known device`() = test { + val knownDevice = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "zpubNS")) + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.ensureConnected(DEVICE_ID) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) + verify(hwWalletStore, never()).saveKnownDevices(any()) + verify(trezorService, never()).getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = any(), + ) + } + @Test fun `connectKnownDevice should rethrow cancellation and clear connecting state`() = test { val cancellation = CancellationException("cancelled") diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 254089fb7d..69dd58e604 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -165,6 +165,37 @@ class TrezorBridgeTransportTest { assertEquals(listOf(0x01), result.data.toList()) } + @Test + fun `button ack call uses longer read timeout than bridge management requests`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 27u, byteArrayOf()) + + assertTrue(result.success) + assertEquals(18u.toUShort(), result.messageType) + assertEquals(listOf(0x01), result.data.toList()) + } + @Test fun `non signing call uses bridge management read timeout`() { server.route = { request -> 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/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 8b9e64ff12..f460860934 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.sheets import android.content.Context +import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.FeeRates @@ -110,6 +111,19 @@ class BoostTransactionViewModelTest : BaseUnitTest() { } } + private suspend fun setupActivity(activity: Activity.Onchain) { + whenever(activityRepo.getActivity(activity.v1.id, activity.v1.walletId)).thenReturn(Result.success(activity)) + sut.setupActivity(activity.v1.id, activity.v1.walletId) + } + + private suspend fun ReceiveTurbine.awaitLoadedState(): BoostTransactionUiState { + var state = awaitItem() + while (state.loading) { + state = awaitItem() + } + return state + } + @Test fun `setupActivity should set loading state initially`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) @@ -118,7 +132,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(activitySent) + setupActivity(activitySent) val loadingState = awaitItem() assertTrue(loadingState.loading) @@ -132,7 +146,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) @@ -145,7 +159,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateCpfpFeeRate(eq(mockTxId))) .thenReturn(Result.success(feeRate)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) verify(lightningRepo).calculateCpfpFeeRate(eq(mockTxId)) verify(lightningRepo, never()).getFeeRateForSpeed(any(), anyOrNull()) @@ -176,7 +190,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = true) @@ -190,7 +204,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = false) @@ -203,7 +217,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.failure(Exception("error"))) sut.boostTransactionEffect.test { - sut.setupActivity(activitySent) + setupActivity(activitySent) assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) } } @@ -225,7 +239,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(activityRepo.updateActivity(any(), any(), any())).thenReturn(Result.success(Unit)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) sut.boostTransactionEffect.test { sut.onConfirmBoost() @@ -248,9 +262,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(fastFeeTime, state.estimateTime) } } @@ -263,9 +276,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(normalFeeTime, state.estimateTime) } } @@ -279,9 +291,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(flowFeeTime, state.estimateTime) } } @@ -295,9 +306,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(minFeeTime, state.estimateTime) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..e3e0a1a61c 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,58 @@ 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> + private val bitkitSent = onchainActivity(id = "bitkit-sent", txType = PaymentType.SENT, timestamp = 300uL) + private val bitkitReceived = onchainActivity( + id = "bitkit-received", + txType = PaymentType.RECEIVED, + timestamp = 250uL, + ) + + private val hwReceived = onchainActivity( + id = "hw-received", + txType = PaymentType.RECEIVED, + timestamp = 200uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev1"), + ) + + private val hwSent = onchainActivity( + id = "hw-sent", + txType = PaymentType.SENT, + timestamp = 150uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev1"), + ) + + private val hwSecondWallet = onchainActivity( + id = "hw-second-wallet", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev2"), + ) + + private val activities = listOf(hwSecondWallet, bitkitSent, hwSent, bitkitReceived, hwReceived) @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(activities)) whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) } @@ -64,63 +88,84 @@ 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() - assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals( + listOf("bitkit-sent", "bitkit-received", "hw-received", "hw-sent", "hw-second-wallet"), + sut.filteredActivities.value?.map { it.rawId() }, + ) } @Test fun `filtered activities exclude hardware activities not matching the tab`() = test { + 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(bitkitSent, hwSent))) val sut = createViewModel() sut.setTab(ActivityTab.SENT) advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals(listOf("bitkit-sent", "hw-sent"), sut.filteredActivities.value?.map { it.rawId() }) } @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 { + 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(hwReceived, hwSent, hwSecondWallet))) val sut = createViewModel() sut.toggleTag("tag1") advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) - } - - @Test - fun `hardware ids expose the hardware activity ids`() = test { - val sut = createViewModel() - val job = launch { sut.hardwareIds.collect {} } - advanceUntilIdle() - - assertEquals(setOf("hw1"), sut.hardwareIds.value) - job.cancel() + assertEquals( + listOf("hw-received", "hw-sent", "hw-second-wallet"), + sut.filteredActivities.value?.map { it.rawId() }, + ) } @Test - fun `hardware duplicates of local activities are excluded`() = test { - hardwareActivities.value = persistentListOf( - hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), - ) + 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(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(setOf(hwReceived.scopedId(), hwSent.scopedId(), hwSecondWallet.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 +175,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 c4df1976d1..ce88009be7 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 @@ -279,16 +280,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/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f6bc83f01e..0a604d589d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -217,6 +217,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) @@ -247,7 +249,9 @@ class TransferViewModelTest : BaseUnitTest() { eq(TXID), eq(MINING_FEE), eq(FEE_RATE), + eq(HW_WALLET_ID), ) + verify(hwWalletRepo).getWalletId(DEVICE_ID) verify(hwWalletRepo).ensureConnected(DEVICE_ID) } @@ -269,6 +273,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) @@ -292,6 +298,8 @@ class TransferViewModelTest : BaseUnitTest() { val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(RuntimeException("no device"))) @@ -316,6 +324,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) @@ -363,6 +373,7 @@ class TransferViewModelTest : BaseUnitTest() { const val SERVICE_FEE = 286uL const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE const val DEVICE_ID = "dev1" + const val HW_WALLET_ID = "trezor:dev1" const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL 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 a80d35b4d6..e939233749 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.75" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.9" } 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 12d6411d6e..c5b247fdfb 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,13 +60,14 @@ Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `activity-blue-icons.xml` | 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 | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, rename sheet, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | -| `transfer-to-spending.xml` | Happy-path transfer amount → sign → processing flow with a valid amount below the cap | +| `transfer-to-spending.xml` | Happy-path transfer amount → sign → same-backend confirmation → spending balance and hardware transfer activity | | `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | | `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | @@ -101,6 +102,57 @@ out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, send to an address generated via Dev Settings → Trezor → Get Address, then mine a block with `./bitcoin-cli`. +## Hardware Activity Fixture + +Do not validate hardware activity journeys against a single transaction. Before running +`activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, or the final assertions in +`transfer-to-spending.xml`, set up a mixed hardware history: + +- At least two confirmed hardware-wallet receive transactions with distinct amounts. +- At least one hardware-wallet Transfer To Spending transaction. +- Prefer at least one normal app activity in the unified Activity list when the run already + has one, so filters prove hardware and app-owned entries coexist. + +When this fixture is used with Transfer To Spending, create the receive and transfer history +on the same backend described below. Use distinct amounts and note the latest transfer amount +before opening Activity rows. The hardware transfer row should render once with a blue hardware +icon, title `Transfer`, and subtitle `From Savings`; its detail screen should show +`TO SPENDING`. If the same transfer also appears as a separate default-wallet row with a +normal `TransferIcon` or the same amount appears twice for the same new tx, the journey should +fail. + +## Transfer To Spending backend rule + +The Transfer To Spending journey must use one regtest chain end to end. The app creates the +Blocktank order through the dev/staging Blocktank API, so the hardware wallet funding +transaction must also be composed, broadcast, funded and mined on the matching staging +Electrum/backend. + +For current dev runs, set the app Electrum server to: + +```sh +ssl://electrs.bitkit.stag0.blocktank.to:9999 +``` + +Fund the Trezor account and mine confirmations through the same backend, for example: + +```sh +./lsp POST /regtest/chain/deposit '{"address":"","amountSat":25000000}' +./lsp POST /regtest/chain/mine '{"count":1}' +``` + +Do not run Transfer To Spending with the app pointed at local Electrum +`tcp://127.0.0.1:60001` while Blocktank is staging. That confirms the funding transaction on +the local chain only, leaves the staging Blocktank order unpaid, and strands the app on +Processing Payment. + +After hardware signing and broadcast, Processing Payment is an intermediate checkpoint only. +Before mining, tap `Continue Using Bitkit`, verify the app returns home, and verify the new +hardware Transfer / From Savings row exists once with a pending or otherwise non-confirmed +status. Then mine the same backend, wait for the app/Core sync to observe the confirmation, +verify Spending balance updates, and verify the same hardware transfer row/detail becomes +confirmed. + For transfer-to-spending QA, explicitly cover the LSP cap boundary: the hardware wallet balance can be much larger than the displayed AVAILABLE amount because MAX is capped by Blocktank channel headroom. After signing, decode the funding transaction and compare the diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c0..f5fd12b1e0 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,39 +1,39 @@ - Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the current watch-only - activity detail fallback until Core-backed hardware activity support lands. Requires a - paired Bridge emulator whose wallet has at least one on-chain transaction (run - connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). + Verifies hardware wallet on-chain activity in the home list and the All Activity screen + with blue icon variants, mixed transaction history, and tab filter behavior. Hardware + activities are first-class Bitkit Core activities persisted by the watcher, so they appear + in the unified list and survive filters like normal transactions. Requires a paired Bridge + emulator with the mixed hardware activity fixture from README.md: at least two confirmed + hardware receives with distinct amounts and one hardware Transfer To Spending transaction. Launch the Bitkit app and go to the wallet home screen - Verify the recent activity list contains at least one item whose circular icon is blue instead of orange + Verify the recent activity list contains at least one blue hardware activity row, then tap "Show All" beneath the activity list - Tap the first activity item with a blue icon + Verify the All Activity list contains at least three blue hardware rows from the fixture: two received rows with distinct amounts and one Transfer row with subtitle "From Savings" - Verify an activity detail screen opens showing a blue icon and an on-chain amount + Verify the hardware Transfer row appears only once in All Activity; fail if the same new transfer amount is also shown as a separate normal TransferIcon/default-wallet row - Navigate back, then tap "Show All" beneath the activity list + Tap the blue hardware Transfer row with subtitle "From Savings" - Verify the All Activity list also contains items with blue icons + Verify an activity detail screen opens showing a blue hardware icon, title "From Savings", status content, and "TO SPENDING", then navigate back to All Activity - Tap the "Sent" tab and verify no blue-icon item with a received (downward) arrow is listed + Tap the "Sent" tab and verify the blue hardware Transfer row is listed, while the two blue received rows are not listed - Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions + Tap the "Received" tab and verify the two distinct blue received rows are listed, while the hardware Transfer row is not listed - 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 hardware received rows and blue hardware Transfer row are listed again diff --git a/journeys/hardware-wallet/activity-detail-hw-tags.xml b/journeys/hardware-wallet/activity-detail-hw-tags.xml new file mode 100644 index 0000000000..9fbda2fe15 --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,42 @@ + + + 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 with the mixed hardware + activity fixture from README.md. 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 "Show All" beneath the activity list + + + In All Activity, tap a blue hardware received activity row with a distinct amount, not the Transfer / From Savings row + + + Verify an activity detail screen opens showing a blue icon, a received on-chain amount, and status content for that selected hardware transaction + + + 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 All Activity, then tap the same blue hardware received 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 All Activity + + + Open the tag filter, select the "hwtest" tag, and verify only the tagged blue hardware activity remains listed from the hardware fixture; the other untagged blue received or transfer rows should not match the tag filter + + + diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index 8d13bda3c6..8296b52f0b 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -4,7 +4,9 @@ the top bar, balance header, the Transfer To Spending entry when funds are present, the grouped activity list, and the Remove device confirm dialog. The final Remove step forgets the device, so run this last (re-run connect-home-tile.xml to pair - again). Requires a paired Bridge emulator (run connect-home-tile.xml first). + again). Requires a paired Bridge emulator (run connect-home-tile.xml first) and the mixed + hardware activity fixture from README.md, so this journey verifies multiple rows instead + of a single hardware transaction. @@ -17,10 +19,13 @@ Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it, verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it; if the first-run Transfer To Spending intro appears, tap "Get Started"; verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step - If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back + Verify the detail activity list shows multiple blue hardware rows for the paired wallet, including at least one received row and one Transfer / From Savings row, and does not show a duplicate normal TransferIcon row for the same hardware transfer amount + + + Tap one blue hardware row, verify an activity detail screen opens with a blue icon, then navigate back Tap the "Remove" button labelled with the device name near the bottom of the screen (testTag "RemoveHardwareWallet") diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml index a36e8485d6..e3c6892a89 100644 --- a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -3,13 +3,21 @@ Verifies that a funded hardware wallet whose balance is larger than the current Blocktank/LSP headroom is capped by the transferable spending limit, not by the device balance. Requires a paired Bridge emulator or physical Trezor with a hardware - balance larger than the available transfer limit, and at least one existing channel or - pending order consuming most of the regtest LSP cap. + balance larger than the available transfer limit, at least one existing channel or + pending order consuming most of the regtest LSP cap, and the mixed hardware activity + fixture from README.md. The app Electrum backend, hardware funding backend, and + Blocktank regtest backend must match. Launch the Bitkit app and go to the wallet home screen + + Verify the app Electrum backend matches the Blocktank regtest backend before starting the transfer; for current dev/staging runs the app must use ssl://electrs.bitkit.stag0.blocktank.to:9999 and the funding/mining steps must use ./lsp /regtest/chain commands, not local ./bitcoin-cli + + + Before starting, verify the home or hardware wallet detail Activity list already contains at least one blue hardware received row with a distinct amount; this prior-history control must remain visible after the capped transfer completes + Verify the hardware wallet tile shows a balance larger than the AVAILABLE amount expected in the transfer flow @@ -19,6 +27,9 @@ Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom @@ -38,7 +49,22 @@ Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and approve every hardware-wallet prompt - Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress + Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress as an intermediate state + + + Before mining, tap "Continue Using Bitkit" (testTag "TransferSuccess-button") and verify the app returns to the wallet home screen + + + Before mining, verify the recent Activity list contains exactly one hardware wallet transfer row for the new tx, with the blue hardware icon, title "Transfer", subtitle "From Savings", and a non-confirmed/non-stuck transfer status, above the prior blue received row from the fixture + + + Mine one block on the same Blocktank regtest backend with ./lsp POST /regtest/chain/mine '{"count":1}', then wait for the app/Core sync to observe the confirmation + + + Verify the SPENDING balance has increased by the capped AVAILABLE amount and the same hardware wallet transfer row for the new tx is now confirmed + + + Verify the capped transfer amount is not duplicated as a second default-wallet row with a normal TransferIcon or the same Transfer / From Savings text diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml index 518131da38..e2ee7e3e33 100644 --- a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -3,9 +3,13 @@ Verifies that starting a hardware-wallet transfer while the Lightning node is still warming up does not fail or strand the CTA in loading. The flow should show the normal loading/progress UI, continue once the node reaches the needed state, and land on the - sign screen. Requires a paired and funded hardware wallet. + sign screen. Requires a paired and funded hardware wallet on the same regtest backend + used by Blocktank. + + Verify the app Electrum backend matches the Blocktank regtest backend before starting the transfer; for current dev/staging runs the app must use ssl://electrs.bitkit.stag0.blocktank.to:9999 and setup funding must use ./lsp /regtest/chain commands, not local ./bitcoin-cli + adb: adb shell am force-stop to.bitkit.dev @@ -21,6 +25,9 @@ Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 4c628a7b4b..c2681f585a 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -1,23 +1,37 @@ Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With - Your Device -> Transaction Signed -> Processing Payment. The funding send is signed on the - Bridge emulator device, so no physical Trezor is required. Requires a paired Bridge emulator - (run connect-home-tile.xml first) whose native-segwit account holds spendable regtest funds, - and the bitkit-docker stack (Blocktank + regtest) running so the channel order can be created - and funded. Mirrors the on-chain Transfer To Spending flow, swapping local signing for the - device. + Your Device -> Transaction Signed -> Processing Payment -> same-backend confirmation -> + activity verification. The funding send is signed on the Bridge emulator device, so no + physical Trezor is required. Requires a paired Bridge emulator (run connect-home-tile.xml + first) whose native-segwit account holds spendable regtest funds on the same regtest backend + used by Blocktank. Run with the mixed hardware activity fixture from README.md so the final + Activity assertions prove the new transfer coexists with older hardware receive rows and is + not duplicated as a default-wallet activity. Mirrors the on-chain Transfer To Spending flow, + swapping local signing for the device. Launch the Bitkit app and go to the wallet home screen + + Verify the app Electrum backend matches the Blocktank regtest backend before starting the transfer; for current dev/staging runs the app must use ssl://electrs.bitkit.stag0.blocktank.to:9999 and the funding/mining steps must use ./lsp /regtest/chain commands, not local ./bitcoin-cli + + + Before starting, verify the home or hardware wallet detail Activity list already contains at least one blue hardware received row with a distinct amount; this is the prior-history control used to detect ordering and filtering regressions after the new transfer + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + If the hardware wallet balance is zero, fund a Trezor receive address on the same Blocktank regtest backend with ./lsp POST /regtest/chain/deposit '{"address":"<trezor-address>","amountSat":25000000}', mine it with ./lsp POST /regtest/chain/mine '{"count":1}', then wait until the hardware wallet tile/detail balance reflects the funded amount + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad @@ -37,7 +51,28 @@ Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration - Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears + Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears as an intermediate state, not as the final success condition + + + Before mining, tap "Continue Using Bitkit" (testTag "TransferSuccess-button") and verify the app returns to the wallet home screen + + + Before mining, verify the recent Activity list contains exactly one new hardware wallet transfer row for the new tx, with the blue hardware icon, title "Transfer", subtitle "From Savings", and a non-confirmed/non-stuck transfer status, above the prior blue received row from the fixture + + + Before mining, open the new hardware transfer row and verify the Activity detail shows "TO SPENDING" and a pending or otherwise non-confirmed transfer status, then navigate back to the wallet home screen + + + Mine one block on the same Blocktank regtest backend with ./lsp POST /regtest/chain/mine '{"count":1}', then wait for the app/Core sync to observe the confirmation + + + Verify the SPENDING balance has increased by the transfer amount and the same hardware wallet transfer row for the new tx is now confirmed + + + Verify the new transfer amount is not duplicated as a second default-wallet row with a normal TransferIcon or the same Transfer / From Savings text + + + Open the same hardware transfer row again and verify the Activity detail shows "TO SPENDING" and status "Confirmed"