From b2673edc505cd216e729c8f5566a596b0985202c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:38:20 +0200 Subject: [PATCH 01/17] feat: persist hardware activities to core --- .../to/bitkit/repositories/HwWalletRepo.kt | 215 ++++++++---------- .../wallets/activity/ActivityDetailScreen.kt | 176 +++++++------- .../viewmodels/ActivityDetailViewModel.kt | 113 ++++----- .../viewmodels/ActivityListViewModel.kt | 79 +------ 4 files changed, 248 insertions(+), 335 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 0f3f0a7df..f70155d85 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -34,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.isTrezorUserCancellation 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 @@ -45,6 +46,7 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.hwWalletIdentityKey import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType @@ -55,9 +57,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ceil -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime /** * Production hardware-wallet business layer. Tracks paired Trezor devices as @@ -68,14 +68,12 @@ import kotlin.time.ExperimentalTime * and the underlying watcher transport. */ @Suppress("TooManyFunctions") -@OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, - private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -83,9 +81,6 @@ class HwWalletRepo @Inject constructor( private const val WATCHER_ID_SEPARATOR = "|" private val WATCHER_START_RETRY_DELAY = 30.seconds const val DEVICE_LABEL_MAX_LENGTH = 50 - - /** Trezor v1 (2.4.0) tracks native segwit only; multi-type HW support is follow-up work. */ - private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey) } private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) @@ -108,8 +103,6 @@ class HwWalletRepo @Inject constructor( fun onAppForegrounded() = trezorRepo.onAppForegrounded() - fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId) - suspend fun resetState() = withContext(ioDispatcher) { activeWatchers.toList().forEach { watcherId -> trezorRepo.stopWatcher(watcherId) @@ -157,7 +150,14 @@ class HwWalletRepo @Inject constructor( suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) - suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(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, @@ -166,7 +166,7 @@ class HwWalletRepo @Inject constructor( runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } - val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + val groupIds = devices.filter { it.hwWalletIdentityKey == target.hwWalletIdentityKey }.map { it.id }.toSet() val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" } @@ -231,10 +231,7 @@ class HwWalletRepo @Inject constructor( ).getOrThrow() } if (signed.isFailure) { - val failure = signed.exceptionOrNull() - if (failure?.isTrezorUserCancellation() != true) { - trezorRepo.disconnectStaleSession(deviceId) - } + trezorRepo.disconnectStaleSession(deviceId) } val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() HwFundingBroadcastResult( @@ -258,7 +255,7 @@ class HwWalletRepo @Inject constructor( val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } val customLabel = label.trim().take(DEVICE_LABEL_MAX_LENGTH).ifEmpty { null } val updated = devices.map { - if (it.walletKey == target.walletKey) it.copy(customLabel = customLabel) else it + if (it.hwWalletIdentityKey == target.hwWalletIdentityKey) it.copy(customLabel = customLabel) else it } hwWalletStore.saveKnownDevices(updated) } @@ -276,7 +273,11 @@ class HwWalletRepo @Inject constructor( val target = knownDevices.find { it.id == deviceId } val ids = when (target) { null -> setOf(deviceId) - else -> knownDevices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + else -> + knownDevices + .filter { it.hwWalletIdentityKey == target.hwWalletIdentityKey } + .map { it.id } + .toSet() } activeWatchers.toList() .filter { it.toDeviceId() in ids } @@ -287,7 +288,14 @@ 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" } - }.onFailure { + + target?.walletId?.takeIf { it.isNotBlank() } + }.fold( + onSuccess = { + if (it == null) Result.success(Unit) else activityRepo.deleteActivitiesForWallet(it) + }, + onFailure = { Result.failure(it) }, + ).onFailure { watcherSyncRequests.tryEmit(Unit) } } @@ -302,7 +310,7 @@ class HwWalletRepo @Inject constructor( // identity, so group by them to show one wallet and count its balance once. data.knownDevices .filter { it.xpubs.isNotEmpty() } - .groupBy { it.walletKey } + .groupBy { it.hwWalletIdentityKey } .map { (_, devices) -> val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } @@ -336,21 +344,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() @@ -360,19 +353,23 @@ 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.activities.toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - 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 { return@collect } + emitReceivedTxs(previous, activities, updatedWatcherData) } } @@ -388,17 +385,18 @@ class HwWalletRepo @Inject constructor( watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.mapNotNull { activity -> - (activity as? Activity.Onchain)?.v1?.txId - }.toSet() + val knownActivityIds = previous.activities.map { it.scopedId() }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - activities.filterIsInstance() - .filter { it.v1.txType == PaymentType.RECEIVED } - .forEach { onchain -> - val txid = onchain.v1.txId - if (txid in knownTxIds || !emittedReceivedTxIds.add(txid)) return@forEach - val sats = mergedActivities.findOnchain(txid)?.v1?.value ?: onchain.v1.value - _receivedTxs.emit(HwWalletReceivedTx(txid = txid, sats = sats)) + activities + .filterIsInstance() + .filter { + it.v1.txType == PaymentType.RECEIVED && + it.scopedId() !in knownActivityIds && + emittedReceivedTxIds.add(it.scopedId()) + } + .forEach { + 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)) } } @@ -419,63 +417,63 @@ class HwWalletRepo @Inject constructor( ) { desired, _ -> desired }.collect { (knownDevices, watcherSettings) -> - // Trezor v1 watches native segwit only (not global Advanced address-type monitoring). - // Xpubs for all types are still captured on connect for a future multi-type release. - // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. - val filtered = knownDevices.flatMap { device -> - val walletId = device.resolvedWalletId() - device.xpubs - .filterKeys { it in SUPPORTED_WATCHER_ADDRESS_TYPES } - .map { (addressType, xpub) -> - WatcherSpec( - deviceId = device.id, - addressType = addressType, - xpub = xpub, - electrumUrl = watcherSettings.electrumUrl, - walletId = walletId, - ) - } - }.distinctBy { it.addressType to it.xpub } - val filteredIds = filtered.map { it.watcherId }.toSet() - - filtered.forEach { spec -> - val isActive = spec.watcherId in activeWatchers - if ( - isActive && - activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && - activeWatcherWalletIds[spec.watcherId] == spec.walletId - ) { - return@forEach - } - if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach - - trezorRepo.startWatcher( - watcherId = spec.watcherId, - extendedKey = spec.xpub, - network = Env.network.toCoreNetwork(), - accountType = spec.addressType.toAddressType()?.toAccountType(), - electrumUrl = spec.electrumUrl, - walletId = spec.walletId, - ).onSuccess { - activeWatchers += spec.watcherId - activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl - activeWatcherWalletIds[spec.watcherId] = spec.walletId - retryingWatcherStarts -= spec.watcherId - }.onFailure { - Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) - scheduleWatcherStartRetry(spec.watcherId) - } - } + 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 @@ -494,9 +492,6 @@ class HwWalletRepo @Inject constructor( } } - private fun KnownDevice.resolvedWalletId(): String = - walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs, id) - private fun List.toMergedActivities(): List = flatMap { it.activities } .groupBy { it.rawId() } @@ -515,7 +510,6 @@ class HwWalletRepo @Inject constructor( 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 feeRate = onchainActivities.maxOf { it.v1.feeRate } val txType = when { received > sent -> PaymentType.RECEIVED sent > received -> PaymentType.SENT @@ -523,7 +517,7 @@ class HwWalletRepo @Inject constructor( } val value = when (txType) { PaymentType.RECEIVED -> received.safe() - sent.safe() - PaymentType.SENT -> sent.safe() - received.safe() + PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() } return Activity.Onchain( @@ -531,7 +525,6 @@ class HwWalletRepo @Inject constructor( txType = txType, value = value, fee = fee, - feeRate = feeRate, address = onchainActivities.firstOrNull { it.v1.address.isNotBlank() }?.v1?.address.orEmpty(), confirmed = onchainActivities.any { it.v1.confirmed }, isBoosted = onchainActivities.any { it.v1.isBoosted }, @@ -549,15 +542,15 @@ class HwWalletRepo @Inject constructor( ) } - private fun List.findOnchain(txid: String) = filterIsInstance() - .firstOrNull { it.v1.txId == txid } + private fun List.findOnchain(txid: String, walletId: String) = filterIsInstance() + .firstOrNull { it.v1.txId == txid && it.v1.walletId == walletId } private data class WatcherSpec( val deviceId: String, + val walletId: String, val addressType: String, val xpub: String, val electrumUrl: String, - val walletId: String, ) { val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" } @@ -572,14 +565,6 @@ private data class WatcherSettings( val electrumUrl: String, ) -/** - * Cross-transport identity of the wallet a device entry tracks: entries created by - * pairing the same physical device over different transports share the same xpubs. - * Entries without captured xpubs fall back to their own transport-level id. - */ -private val KnownDevice.walletKey: String - get() = xpubs.values.sorted().joinToString().ifEmpty { id } - /** * Resolves the name shown for a hardware wallet: the Bitkit-side custom label if the user set one, * otherwise the device's own label; without one (or with the factory default that just mirrors the 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 2a295347c..b044bcfb0 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/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6..faf84fe08 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,30 @@ class ActivityDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { 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) _uiState.update { it.copy( activityLoadState = ActivityLoadState.Error( @@ -89,57 +96,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 @@ -149,21 +121,17 @@ class ActivityDetailViewModel @Inject constructor( loadTags() } } - .onFailure { error -> - 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) _tags.update { persistentListOf() } } } @@ -171,48 +139,44 @@ 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) - } } } 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) - } } } fun detachContact() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id) - }.onFailure { - Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) + reloadActivity(id, walletId) } } } 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 +237,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 +261,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 df932631a..e5239801e 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() } From bf8e9e6c9e61ca1f3677e3e0a35268f04966b5ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:42:33 +0200 Subject: [PATCH 02/17] test: add hardware activity tags journey --- journeys/hardware-wallet/README.md | 3 +- .../hardware-wallet/activity-blue-icons.xml | 14 +++---- .../activity-detail-hw-tags.xml | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 journeys/hardware-wallet/activity-detail-hw-tags.xml diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 12d6411d6..158019896 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,7 +60,8 @@ Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `activity-blue-icons.xml` | Hardware activity in the unified list, blue icons, All Activity tab filters | +| `activity-detail-hw-tags.xml` | Hardware activity detail tags (persist + survive tag filter) and Explore inputs/outputs | | `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect; physical-device chooser path noted separately | | `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, full connect flow (Intro → Searching → Found → Paired → Finish) re-pairs | | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c..34b0032ab 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,11 +1,11 @@ - Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the current watch-only - activity detail fallback until Core-backed hardware activity support lands. Requires a - paired Bridge emulator whose wallet has at least one on-chain transaction (run - connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). + Verifies hardware wallet on-chain activity in the home list and the All Activity screen + with blue icon variants and tab filter behavior. Hardware activities are now first-class + Bitkit Core activities (persisted by the watcher), so they appear in the unified list and + survive tab and tag filters like normal transactions. Requires a paired Bridge emulator + whose wallet has at least one on-chain transaction (run connect-home-tile.xml first; fund + per README.md if the deterministic wallet has no history). @@ -33,7 +33,7 @@ Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions - Apply any tag filter if a tag exists, and verify blue-icon hardware items disappear from the filtered list; skip this step if no tags exist + Tap back to the "All" tab and verify the blue-icon hardware items are listed again diff --git a/journeys/hardware-wallet/activity-detail-hw-tags.xml b/journeys/hardware-wallet/activity-detail-hw-tags.xml new file mode 100644 index 000000000..c457a124c --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,40 @@ + + + Verifies that a hardware-wallet transaction behaves as a first-class Bitkit Core activity: + its detail screen supports tags (which persist and keep the item visible under a tag + filter) and its Explore screen shows the transaction inputs and outputs fetched from the + configured Electrum backend. Requires a paired Bridge emulator whose wallet has at least + one on-chain transaction (run connect-home-tile.xml first; fund per README.md if the + deterministic wallet has no history). Use a hardware seed distinct from the Bitkit wallet + seed so the transaction resolves as a hardware (blue-icon) activity, not a local one. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the first activity item with a blue (hardware) circular icon + + + Verify an activity detail screen opens showing a blue icon and an on-chain amount + + + Tap "Add Tag", enter the tag "hwtest" and confirm it + + + Verify a tag chip labelled "hwtest" is shown on the activity detail screen + + + Navigate back to the home screen, then tap the same blue-icon activity again and verify the "hwtest" tag is still shown (it persisted to Bitkit Core) + + + Tap "Explore", verify the Activity Explorer screen opens and shows an "Inputs" section and an "Outputs" section each listing at least one entry + + + Navigate back to the home screen, then tap "Show All" beneath the activity list + + + Open the tag filter, select the "hwtest" tag, and verify the blue-icon hardware activity remains listed in the filtered results + + + From a2b19d92fbc589403a23412aefbb38a26f82beb1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:42:38 +0200 Subject: [PATCH 03/17] docs: add changelog fragment for hw activities --- changelog.d/next/1029.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1029.changed.md diff --git a/changelog.d/next/1029.changed.md b/changelog.d/next/1029.changed.md new file mode 100644 index 000000000..fcadac60a --- /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. From 94e7e5c87d8da94b55e15357484a459730978f1d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 25 Jun 2026 00:05:43 +0200 Subject: [PATCH 04/17] test: cover hardware activity core integration --- .../ActivityDetailViewModelTest.kt | 105 ++- .../bitkit/repositories/ActivityRepoTest.kt | 221 ++++-- .../bitkit/repositories/HwWalletRepoTest.kt | 650 +++++++----------- .../PreActivityMetadataRepoTest.kt | 29 +- .../bitkit/repositories/TransferRepoTest.kt | 36 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 381 +++++----- .../ui/screens/trezor/TrezorViewModelTest.kt | 16 +- .../viewmodels/ActivityListViewModelTest.kt | 132 ++-- 8 files changed, 806 insertions(+), 764 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index caf7b134f..ff2691cf7 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.idPrefixed("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,15 +61,14 @@ 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(walletId = "wallet0", + OnchainActivity.create( id = ACTIVITY_ID, txType = PaymentType.RECEIVED, txId = 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) @@ -263,7 +260,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { confirmed: Boolean = false, ): Activity.Onchain { return Activity.Onchain( - v1 = OnchainActivity.create(walletId = "wallet0", + v1 = OnchainActivity.create( id = id, txType = PaymentType.RECEIVED, txId = "tx-$id", diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index e81119627..b525164d5 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,8 +15,8 @@ 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.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -29,14 +29,17 @@ 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 to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse 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,8 +65,9 @@ class ActivityRepoTest : BaseUnitTest() { private val testActivity = mock { on { v1 } doReturn testActivityV1 } + private val hardwareWalletId = ActivityWalletType.TREZOR.idPrefixed("dev1") - private val baseOnchainActivity = OnchainActivity.create(walletId = "wallet0", + private val baseOnchainActivity = OnchainActivity.create( id = "base_activity_id", txType = PaymentType.SENT, txId = "base_tx_id", @@ -77,6 +81,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 +99,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 +121,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 +175,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 +212,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -254,7 +271,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 +280,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(walletId = "wallet0", - 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(walletId = "wallet0", - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, - confirmed = true, - ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + 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 @@ -477,8 +546,6 @@ class ActivityRepoTest : BaseUnitTest() { // Mock update for the new activity wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) - // Mock getActivity to return the new activity (for addTagsToActivity check) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) // Mock tags retrieval from the old activity wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) // Mock tags retrieval from the new activity (should be empty so all tags are considered new) @@ -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()) } @@ -592,7 +659,6 @@ class ActivityRepoTest : BaseUnitTest() { val newTags = listOf("tag2", "tag3", "tag4", "") // tag2 exists, empty string should be filtered val expectedNewTags = listOf("tag3", "tag4") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) wheneverBlocking { coreService.activity.appendTags( @@ -608,13 +674,14 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `addTagsToActivity fails when activity not found`() = test { + fun `addTagsToActivity fails when tags lookup fails`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { coreService.activity.tags(activityId) } doThrow AppError("tags failed") val result = sut.addTagsToActivity(activityId, listOf("tag1")) assertTrue(result.isFailure) + verify(coreService.activity, never()).appendTags(any(), any(), anyOrNull()) } @Test @@ -623,13 +690,12 @@ class ActivityRepoTest : BaseUnitTest() { val existingTags = listOf("tag1", "tag2") val duplicateTags = listOf("tag1", "tag2", "") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) 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 @@ -661,7 +727,6 @@ class ActivityRepoTest : BaseUnitTest() { val activityId = "activity123" val tagsToRemove = listOf("tag1", "tag2") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.dropTags(activityId, tagsToRemove) }.thenReturn(Unit) val result = sut.removeTagsFromActivity(activityId, tagsToRemove) @@ -671,11 +736,12 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `removeTagsFromActivity fails when activity not found`() = test { + fun `removeTagsFromActivity fails when dropTags fails`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + val tags = listOf("tag1") + wheneverBlocking { coreService.activity.dropTags(activityId, tags) } doThrow AppError("drop failed") - val result = sut.removeTagsFromActivity(activityId, listOf("tag1")) + val result = sut.removeTagsFromActivity(activityId, tags) assertTrue(result.isFailure) } @@ -771,6 +837,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +890,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +943,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +995,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +1048,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 a1c00adaf..8a08eb132 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -6,7 +6,6 @@ import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -14,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -22,7 +20,6 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq -import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -33,21 +30,21 @@ 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 import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.ext.create 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") @@ -57,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.idPrefixed("dev1") private val device = KnownDevice( id = "dev1", @@ -73,6 +70,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = trezorWalletId, ) @Before @@ -85,14 +83,12 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) - whenever(trezorRepo.deriveWalletId(any(), any())).thenAnswer { invocation -> - val xpubs = invocation.getArgument>(0) - "derived-${xpubs.values.sorted().joinToString()}" - } - 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( @@ -100,7 +96,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -145,205 +140,132 @@ 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.idPrefixed("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.idPrefixed("dev2"), + ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 10_562_411uL), - activities = listOf(watcherActivity(amount = 10_562_411uL)), - transactionDetails = emptyList(), - 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), - activities = emptyList(), transactionDetails = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - activities = emptyList(), transactionDetails = 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), - activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), - transactionDetails = emptyList(), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), - transactionDetails = emptyList(), - 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), - activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), - transactionDetails = emptyList(), - 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), - activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), - transactionDetails = emptyList(), - 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 `merges duplicate sent tx activities without subtracting fee twice`() = test { + 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 fee = 1_000uL + val native = onchainActivity(txid = "shared", amount = 100uL) + val taproot = onchainActivity(txid = "shared", amount = 50uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - activities = listOf( - watcherActivity(amount = 40_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), - ), - transactionDetails = emptyList(), - txCount = 1u, - blockHeight = 1u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(native), + balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - activities = listOf( - watcherActivity(amount = 20_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), - ), - transactionDetails = emptyList(), - txCount = 1u, - blockHeight = 1u, + "dev1|taproot" to transactionsChanged( + activities = listOf(taproot), + balanceTotal = 50uL, accountType = AccountType.TAPROOT, ) ) val activity = sut.wallets.value.single().activities.single() as Activity.Onchain - assertEquals(PaymentType.SENT, activity.v1.txType) - assertEquals(60_000uL, activity.v1.value) - assertEquals(fee, activity.v1.fee) - } - - @Test - fun `preserves activity timestamp across watcher refreshes`() = test { - val sut = createRepo() - val pendingActivity = watcherActivity( - amount = 100uL, - txid = "pending", - blockHeight = null, - timestamp = 1_800_000_000uL, - confirmations = 0u, - ) - - watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - activities = listOf(pendingActivity), - transactionDetails = emptyList(), - txCount = 1u, - blockHeight = 1u, - 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), - activities = listOf(pendingActivity), - transactionDetails = emptyList(), - txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - val refreshedTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - - assertEquals(1_800_000_000uL, firstTimestamp) - assertEquals(firstTimestamp, refreshedTimestamp) + assertEquals(PaymentType.RECEIVED, activity.v1.txType) + assertEquals("shared", activity.v1.txId) + assertEquals(150uL, activity.v1.value) + assertEquals(150uL, sut.wallets.value.single().balanceSats) } @Test - fun `starts native segwit watcher regardless of monitored address types`() = test { + fun `starts watchers only for the address types the user monitors`() = test { storeData.value = HwWalletData( knownDevices = listOf( device.copy( @@ -360,9 +282,12 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any(), 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 @@ -375,12 +300,12 @@ 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), - walletId = any(), ) } @@ -401,84 +326,37 @@ 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), - walletId = any(), ) } @Test - fun `restarts active watchers when wallet id changes`() = test { - val derivedWalletId = "derived-zpubNS" - storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = "legacy-wallet-id"))) - wheneverStartWatcher().thenReturn(Result.success(Unit)) - whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) + fun `retries watcher start after failure`() = test { + wheneverStartWatcher().thenReturn(Result.failure(AppError("start failed")), Result.success(Unit)) createRepo() - runCurrent() - val order = inOrder(trezorRepo) - order.verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), - extendedKey = eq("zpubNS"), - network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), - accountType = anyOrNull(), - electrumUrl = any(), - walletId = eq("legacy-wallet-id"), - ) - - storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = derivedWalletId))) - runCurrent() - - order.verify(trezorRepo).stopWatcher("dev1|nativeSegwit") - order.verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), - extendedKey = eq("zpubNS"), - network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), - accountType = anyOrNull(), - electrumUrl = any(), - walletId = eq(derivedWalletId), - ) - } + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) - @Test - fun `starts watchers with derived wallet id when store value is blank`() = test { - storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = ""))) - wheneverStartWatcher().thenReturn(Result.success(Unit)) - - createRepo() + advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo).startWatcher( + verify(trezorRepo, times(2)).startWatcher( watcherId = eq("dev1|nativeSegwit"), - extendedKey = eq("zpubNS"), - network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + walletId = any(), + extendedKey = any(), + network = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = any(), - walletId = eq("derived-zpubNS"), ) } - @Test - fun `retries watcher start after failure`() = test { - wheneverStartWatcher().thenReturn(Result.failure(AppError("start failed")), Result.success(Unit)) - - createRepo() - - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - - advanceTimeBy(30.seconds) - runCurrent() - - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - } - @Test fun `emits received tx only for new inbound transactions after the baseline sync`() = test { val sut = createRepo() @@ -487,45 +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), - activities = listOf(watcherActivity(amount = 100uL)), - transactionDetails = emptyList(), + "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), + "dev1|nativeSegwit" to transactionsChanged( activities = listOf( - watcherActivity(amount = 100uL), - watcherActivity(amount = 50uL, txid = "t2"), + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), - transactionDetails = emptyList(), + 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), + "dev1|nativeSegwit" to transactionsChanged( activities = listOf( - watcherActivity(amount = 100uL), - watcherActivity(amount = 50uL, txid = "t2"), + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), - transactionDetails = emptyList(), + balanceTotal = 150uL, txCount = 2u, - blockHeight = 3u, - accountType = AccountType.NATIVE_SEGWIT, ) ) assertEquals(1, received.size) @@ -535,51 +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), - activities = emptyList(), transactionDetails = 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), - activities = emptyList(), transactionDetails = 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), - activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), - transactionDetails = emptyList(), - 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), - activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), - transactionDetails = emptyList(), - 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() } @@ -590,24 +445,13 @@ class HwWalletRepoTest : BaseUnitTest() { val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - activities = emptyList(), transactionDetails = 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), - activities = listOf( - watcherActivity(amount = 60uL, txid = "t3", txType = PaymentType.SENT), - ), - transactionDetails = emptyList(), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t3", amount = 60uL, txType = PaymentType.SENT)), + balanceTotal = 40uL, txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -624,17 +468,22 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), 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), - activities = listOf(watcherActivity(amount = 421_900uL)), - transactionDetails = emptyList(), + "ble1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t1", amount = 421_900uL)), + balanceTotal = 421_900uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -669,41 +518,23 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `keeps a stale watcher until stopping it succeeds`() = test { storeData.value = HwWalletData( - knownDevices = listOf( - device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR")), - ) + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) ) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.failure(AppError("stop failed"))) val sut = createRepo() - runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - activities = emptyList(), transactionDetails = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) - runCurrent() - // Dropping the native-segwit xpub stops the watcher; a failed stop keeps ghost balance visible. - storeData.value = HwWalletData( - knownDevices = listOf(device.copy(xpubs = mapOf("taproot" to "zpubTR"))), - ) - runCurrent() + // Stop fails: the watcher data must survive so the balance is not silently wrong. + settingsData.value = SettingsData(addressTypesToMonitor = emptyList()) assertEquals(100uL, sut.totalSats.value) // Stop succeeds on a later sync: the watcher data is finally dropped. whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) - storeData.value = HwWalletData( - knownDevices = listOf( - device.copy(xpubs = mapOf("taproot" to "zpubTR"), lastConnectedAt = 2L), - ), - ) - runCurrent() + settingsData.value = SettingsData(addressTypesToMonitor = listOf("taproot")) assertEquals(0uL, sut.totalSats.value) } @@ -717,13 +548,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - activities = emptyList(), transactionDetails = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) sut.resetState() @@ -734,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)) @@ -747,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 @@ -821,40 +674,54 @@ 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(), - walletId = any(), ) } @Test - fun `forwards transport restored to the trezor repo`() = test { + fun `restarts active watchers when wallet id changes`() = test { + val newWalletId = ActivityWalletType.TREZOR.idPrefixed("new-wallet") + whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) val sut = createRepo() + runCurrent() - sut.onTransportRestored(TransportType.USB) + storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = newWalletId))) + runCurrent() - verify(trezorRepo).onTransportRestored(TransportType.USB) + 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(), + ) } @Test - fun `forwards app foregrounded to the trezor repo`() = test { + fun `forwards transport restored to the trezor repo`() = test { val sut = createRepo() - sut.onAppForegrounded() + sut.onTransportRestored(TransportType.USB) - verify(trezorRepo).onAppForegrounded() + verify(trezorRepo).onTransportRestored(TransportType.USB) } @Test - fun `forwards warm up known device to the trezor repo`() = test { + fun `forwards app foregrounded to the trezor repo`() = test { val sut = createRepo() - sut.warmUpKnownDevice("dev1") + sut.onAppForegrounded() - verify(trezorRepo).warmUpKnownDevice("dev1") + verify(trezorRepo).onAppForegrounded() } @Test @@ -969,26 +836,6 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).broadcastRawTx(any()) } - @Test - fun `signAndBroadcastFunding keeps session when user cancels on device`() = test { - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = 1_250uL, - feeRate = 2.0f, - totalSpent = 26_250uL, - satsPerVByte = 2uL, - ) - whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) - .thenReturn(Result.failure(TrezorException.UserCancelled())) - val sut = createRepo() - - val result = sut.signAndBroadcastFunding("dev1", funding) - - assertEquals(true, result.isFailure) - verify(trezorRepo, never()).disconnectStaleSession(any()) - verify(trezorRepo, never()).broadcastRawTx(any()) - } - @Test fun `forwards pairing code calls to the trezor repo`() = test { val sut = createRepo() @@ -1011,47 +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(), - walletId = any(), ) } - private fun walletBalance(total: ULong) = WalletBalance( - confirmed = total, - immature = 0uL, - trustedPending = 0uL, - untrustedPending = 0uL, - spendable = total, - total = total, - ) - - private fun watcherActivity( - amount: ULong, - txid: String = "t1", - txType: PaymentType = PaymentType.RECEIVED, - blockHeight: UInt? = 850_000u, - timestamp: ULong? = 1_700_000_000uL, - confirmations: UInt = 3u, - fee: ULong = 0uL, - ) = Activity.Onchain( - OnchainActivity.create( - walletId = "wallet0", - id = txid, - txType = txType, - txId = txid, - value = amount, - fee = fee, - address = "", - timestamp = timestamp ?: 0uL, - confirmed = blockHeight != null && confirmations > 0u, - ) - ) - - @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) @@ -1130,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(), @@ -1137,7 +1009,7 @@ class HwWalletRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), - any(), + 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 d1c8409d4..a8a8eff9b 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"), @@ -45,8 +47,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { isReceive = false, feeRate = 10u, isTransfer = false, - channelId = "channel-123", - walletId = "wallet0", + channelId = "channel-123" ) @Before @@ -376,8 +377,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isSuccess) @@ -395,8 +395,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = false, tags = emptyList(), - isTransfer = true, - walletId = "wallet0", + isTransfer = true ) assertTrue(result.isSuccess) @@ -416,8 +415,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { tags = listOf("important"), feeRate = 10u, isTransfer = true, - channelId = "channel-123", - walletId = "wallet0", + channelId = "channel-123" ) assertTrue(result.isSuccess) @@ -434,8 +432,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = emptyList(), - isTransfer = false, - walletId = "wallet0", + isTransfer = false ) assertTrue(result.isFailure) @@ -460,8 +457,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - feeRate = null, - walletId = "wallet0", + feeRate = null ) assertTrue(result.isSuccess) @@ -484,8 +480,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - channelId = null, - walletId = "wallet0", + channelId = null ) assertTrue(result.isSuccess) @@ -503,8 +498,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isFailure) @@ -600,8 +594,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isSuccess) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index f69cf7522..16f64eaa7 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() @@ -291,7 +320,7 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create(walletId = "wallet0", + val activity = OnchainActivity.create( id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -609,7 +638,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = "sweep-txid", @@ -643,6 +672,7 @@ class TransferRepoTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull() ) ) @@ -741,7 +771,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = sweepTxid, diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 8820930a4..8a442664d 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -7,7 +7,6 @@ import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo -import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorSignedMessageResponse @@ -26,7 +25,6 @@ import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -37,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 @@ -45,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 @@ -68,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) @@ -104,6 +120,9 @@ class TrezorRepoTest : BaseUnitTest() { 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( @@ -117,6 +136,9 @@ class TrezorRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, ) + private fun walletIdFor(vararg xpubs: String): String = + ActivityWalletType.TREZOR.idPrefixed(xpubs.sorted().joinToString("|")) + @Suppress("LongParameterList") private fun mockDeviceInfo( id: String = DEVICE_ID, @@ -197,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() @@ -209,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() @@ -469,75 +523,6 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).scan() } - @Test - fun `warmUpKnownDevice connects to the requested bluetooth device`() = test { - val bleDeviceId = "ble:57:21:A7:F9:DD:AD" - val knownDevice = mockKnownDevice( - id = bleDeviceId, - path = bleDeviceId, - transportType = TransportType.BLUETOOTH, - ) - val device = mockDeviceInfo( - id = bleDeviceId, - path = bleDeviceId, - transportType = TrezorTransportType.BLUETOOTH, - ) - val features = mockFeatures() - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) - whenever(trezorService.isConnected()).thenReturn(false) - whenever(trezorService.scan()).thenReturn(listOf(device)) - whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) - sut = createSut() - - sut.initialize() - sut.warmUpKnownDevice(bleDeviceId) - advanceUntilIdle() - - assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) - verify(trezorService).connect(eq(bleDeviceId), any()) - } - - @Test - fun `warmUpKnownDevice skips non-bluetooth devices`() = test { - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) - sut = createSut() - - sut.initialize() - sut.warmUpKnownDevice(DEVICE_ID) - advanceUntilIdle() - - verify(trezorService, never()).scan() - } - - @Test - fun `warmUpKnownDevice skips when device is already connected`() = test { - val bleDeviceId = "ble:57:21:A7:F9:DD:AD" - val knownDevice = mockKnownDevice( - id = bleDeviceId, - path = bleDeviceId, - transportType = TransportType.BLUETOOTH, - ) - val device = mockDeviceInfo( - id = bleDeviceId, - path = bleDeviceId, - transportType = TrezorTransportType.BLUETOOTH, - ) - val features = mockFeatures() - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) - whenever(trezorService.isConnected()).thenReturn(false, true) - whenever(trezorService.scan()).thenReturn(listOf(device)) - whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) - sut = createSut() - sut.initialize() - sut.warmUpKnownDevice(bleDeviceId) - advanceUntilIdle() - - sut.warmUpKnownDevice(bleDeviceId) - advanceUntilIdle() - - verify(trezorService, times(1)).scan() - } - @Test fun `onTransportRestored skips usb device without permission`() = test { val device = mockDeviceInfo() @@ -649,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 @@ -693,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( @@ -1154,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 @@ -1195,68 +1301,47 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(features, result.getOrNull()) assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) verify(trezorService).connect(eq(bleDeviceId), any()) - verify(trezorService).scan() - } - - @Test - fun `connectKnownDevice should rethrow cancellation and clear connecting state`() = test { - val cancellation = CancellationException("cancelled") - whenever(trezorService.scan()).thenAnswer { throw cancellation } - sut = createSut() - - sut.initialize() - val thrown = assertFailsWith { - sut.connectKnownDevice(DEVICE_ID) - } - - assertEquals(cancellation.message, thrown.message) - assertFalse(sut.state.value.isConnecting) - assertNull(sut.state.value.error) } @Test - fun `disconnectStaleSession should not disconnect unrelated connected device`() = test { - val otherDeviceId = "device-other" - val knownOther = mockKnownDevice(id = otherDeviceId, path = "/other") - val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other") + 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(knownOther)) - whenever(trezorService.scan()).thenReturn(listOf(otherDevice)) - whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features) + 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() - assertTrue(sut.connectKnownDevice(otherDeviceId).isSuccess) - assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) - - val result = sut.disconnectStaleSession(DEVICE_ID) + val result = sut.ensureConnected(DEVICE_ID) assertTrue(result.isSuccess) - assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) - verify(trezorService, never()).disconnect() + 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 failure should not disconnect unrelated connected device`() = test { - val otherDeviceId = "device-other" - val knownOther = mockKnownDevice(id = otherDeviceId, path = "/other") - val knownTarget = mockKnownDevice() - val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other") - val features = mockFeatures() - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownTarget, knownOther)) - whenever(trezorService.scan()).thenReturn(listOf(otherDevice)) - whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features) + fun `connectKnownDevice should rethrow cancellation and clear connecting state`() = test { + val cancellation = CancellationException("cancelled") + whenever(trezorService.scan()).thenAnswer { throw cancellation } sut = createSut() sut.initialize() - assertTrue(sut.connectKnownDevice(otherDeviceId).isSuccess) - - whenever(trezorService.scan()).thenReturn(emptyList()) - val result = sut.connectKnownDevice(DEVICE_ID) + val thrown = assertFailsWith { + sut.connectKnownDevice(DEVICE_ID) + } - assertTrue(result.isFailure) - assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) - verify(trezorService, never()).disconnect() + assertEquals(cancellation.message, thrown.message) + assertFalse(sut.state.value.isConnecting) + assertNull(sut.state.value.error) } @Test @@ -1280,58 +1365,6 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).disconnect() } - @Test - fun `ensureConnected retries bluetooth reconnect until scan finds the device`() = test { - val bleDeviceId = "ble:57:21:A7:F9:DD:AD" - val knownDevice = mockKnownDevice( - id = bleDeviceId, - path = bleDeviceId, - transportType = TransportType.BLUETOOTH, - ) - val device = mockDeviceInfo( - id = bleDeviceId, - path = bleDeviceId, - transportType = TrezorTransportType.BLUETOOTH, - ) - val features = mockFeatures() - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) - whenever(trezorService.isConnected()).thenReturn(false) - whenever(trezorService.scan()).thenReturn(emptyList(), emptyList(), listOf(device)) - whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) - sut = createSut() - - sut.initialize() - val result = sut.ensureConnected(bleDeviceId) - - assertTrue(result.isSuccess) - assertEquals(features, result.getOrNull()) - verify(trezorService, times(3)).scan() - verify(trezorService).connect(eq(bleDeviceId), any()) - verify(trezorService, never()).connect(eq(bleDeviceId), any(), eq(false)) - } - - @Test - fun `ensureConnected stops bluetooth retry after user cancellation`() = test { - val bleDeviceId = "ble:57:21:A7:F9:DD:AD" - val knownDevice = mockKnownDevice( - id = bleDeviceId, - path = bleDeviceId, - transportType = TransportType.BLUETOOTH, - ) - whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) - whenever(trezorService.isConnected()).thenReturn(false) - whenever(trezorService.scan()).thenReturn(emptyList()) - whenever(trezorService.connect(eq(bleDeviceId), any())).doAnswer { throw TrezorException.UserCancelled() } - sut = createSut() - - sut.initialize() - val result = sut.ensureConnected(bleDeviceId) - - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is TrezorException.UserCancelled) - verify(trezorService, times(1)).connect(eq(bleDeviceId), any()) - } - // endregion // region clearError 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 fa1bd073a..0d06a5de2 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(), 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(), 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(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -406,7 +406,7 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - activities = TrezorPreviewData.sampleWatcherActivities, + activities = emptyList(), transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, @@ -425,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(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -434,7 +434,7 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - activities = TrezorPreviewData.sampleWatcherActivities, + activities = emptyList(), transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, @@ -460,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(), 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") @@ -476,7 +476,7 @@ class TrezorViewModelTest : BaseUnitTest() { assertNull(state.activeWatcherId) assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) assertNull(state.watcherBalance) - assertTrue(state.watcherActivities.isEmpty()) + assertEquals(0u, state.watcherTransactionCount) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 57e7fbb6f..211710639 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.idPrefixed("dev1"), + ) + + private val hwSent = onchainActivity( + id = "hw-sent", + txType = PaymentType.SENT, + timestamp = 150uL, + walletId = ActivityWalletType.TREZOR.idPrefixed("dev1"), + ) + + private val hwSecondWallet = onchainActivity( + id = "hw-second-wallet", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = ActivityWalletType.TREZOR.idPrefixed("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,64 +88,85 @@ 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( - OnchainActivity.create(walletId = "wallet0", + private fun onchainActivity( + id: String, + txType: PaymentType, + timestamp: ULong, + walletId: String = ActivityWalletType.BITKIT.id(), + ) = Activity.Onchain( + OnchainActivity.create( id = id, txType = txType, txId = id, @@ -130,6 +175,7 @@ class ActivityListViewModelTest : BaseUnitTest() { address = "bc1", timestamp = timestamp, confirmed = true, + walletId = walletId, ) ) } From 34c6e1563f783aa2078b45ab043a60e1bc231fad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 25 Jun 2026 01:11:34 +0200 Subject: [PATCH 05/17] fix: scope hardware wallet activities --- app/src/main/java/to/bitkit/ext/Activities.kt | 57 ++++- .../to/bitkit/models/ActivityWalletType.kt | 15 ++ .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../models/NewTransactionSheetDetails.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 108 ++++++---- .../java/to/bitkit/repositories/TrezorRepo.kt | 203 ++++------------- .../java/to/bitkit/services/CoreService.kt | 204 +++++++++++------- app/src/main/java/to/bitkit/ui/ContentView.kt | 22 +- .../screens/contacts/ContactActivityScreen.kt | 12 +- .../ui/screens/trezor/TrezorViewModel.kt | 13 +- .../screens/wallets/HardwareWalletScreen.kt | 19 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 6 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 12 +- .../screens/wallets/SpendingWalletScreen.kt | 12 +- .../wallets/activity/ActivityExploreScreen.kt | 93 ++++---- .../wallets/activity/AllActivityScreen.kt | 8 +- .../components/ActivityListGrouped.kt | 103 ++++----- .../activity/components/ActivityListSimple.kt | 10 +- .../activity/components/ActivityRow.kt | 89 ++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 14 +- .../NotifyPaymentReceivedHandlerTest.kt | 20 +- .../viewmodels/AppViewModelSendFlowTest.kt | 7 +- 22 files changed, 525 insertions(+), 504 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/ActivityWalletType.kt diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index f6527a521..960c847e5 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,13 +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.WalletScope +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 @@ -36,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 @@ -78,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 @@ -91,11 +137,15 @@ fun Activity.timestamp() = when (this) { } } +fun Activity.rawTimestamp() = when (this) { + is Activity.Lightning -> v1.timestamp + is Activity.Onchain -> v1.timestamp +} + enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") fun LightningActivity.Companion.create( - walletId: String = WalletScope.default, id: String, txType: PaymentType, status: PaymentState, @@ -109,6 +159,7 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = LightningActivity( walletId = walletId, id = id, @@ -128,7 +179,6 @@ fun LightningActivity.Companion.create( @Suppress("LongParameterList") fun OnchainActivity.Companion.create( - walletId: String = WalletScope.default, id: String, txType: PaymentType, txId: String, @@ -149,6 +199,7 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = OnchainActivity( walletId = walletId, id = id, 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 000000000..f3b110f3b --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,15 @@ +package to.bitkit.models + +import java.util.Locale + +enum class ActivityWalletType { + BITKIT, TREZOR; + + fun id(): String = name.lowercase(Locale.US) + + fun idPrefixed(value: String): String = "${prefix()}$value" + + fun owns(walletId: String): Boolean = walletId == id() || walletId.startsWith(prefix()) + + private fun prefix(): String = "${id()}:" +} diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a0..2c7f3eb48 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 11b4610d7..004c985fd 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 9de8bc1ba..a4e027851 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -34,9 +34,9 @@ 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.create import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis @@ -178,8 +178,8 @@ class ActivityRepo @Inject constructor( private suspend fun findClosedChannelForTransaction(txid: String): String? = coreService.activity.findClosedChannelForTransaction(txid, null) - suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? = - coreService.activity.getOnchainActivityByTxId(txid) + suspend fun getOnchainActivityByTxId(txid: String, walletId: String? = null): OnchainActivity? = + coreService.activity.getOnchainActivityByTxId(txid, walletId) /** * Checks if a transaction is inbound (received) by looking up the payment direction. @@ -214,23 +214,40 @@ 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 = getOnchainActivityByTxId(onchain.v1.txId, 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) } } @@ -258,22 +275,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 { @@ -328,6 +348,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -338,7 +359,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:" + @@ -356,9 +377,9 @@ 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) } @@ -530,12 +551,6 @@ class ActivityRepo @Inject constructor( Logger.debug("Activity $id updated with success. new data: $activity", context = TAG) val tags = coreService.activity.tags(activityIdToDelete) addTagsToActivity(activityId = id, tags = tags) - }.onFailure { - Logger.error( - "updateActivity error: id:$id, activityIdToDelete:$activityIdToDelete activity:$activity", - it, - context = TAG, - ) } } @@ -654,7 +669,8 @@ class ActivityRepo @Inject constructor( val now = nowTimestamp().epochSecond.toULong() insertActivity( Activity.Lightning( - LightningActivity.create( + LightningActivity( + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -685,15 +701,14 @@ 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" } - - 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 { @@ -727,12 +742,14 @@ 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" } - - coreService.activity.dropTags(activityId, tags) + coreService.activity.dropTags(activityId, tags, walletId) notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { @@ -743,9 +760,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/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index e8f6f0af0..6c01ddb23 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -33,9 +33,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -53,17 +51,21 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env -import to.bitkit.ext.isTrezorUserCancellation 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.findHardwareWalletId import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.models.toStandardExtendedKey import to.bitkit.models.toTrezorCoinType +import to.bitkit.models.withHardwareWalletIds +import to.bitkit.models.withStandardExtendedKeys import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -72,11 +74,9 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import to.bitkit.models.HwWalletId import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -102,8 +102,6 @@ class TrezorRepo @Inject constructor( private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4 private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds - private val CONNECT_ATTEMPT_POLL_INTERVAL = 250.milliseconds - private val CONNECT_ATTEMPT_MAX_WAIT = 28.seconds } private val _state = MutableStateFlow(TrezorState()) @@ -647,9 +645,9 @@ class TrezorRepo @Inject constructor( suspend fun connectKnownDevice( deviceId: String, forceSession: Boolean = false, - allowBleFallback: Boolean = true, + refreshKnownDevice: Boolean = true, ): Result = withContext(ioDispatcher) { - if (isConnectInProgress()) { + if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) } var startedConnecting = false @@ -668,21 +666,29 @@ class TrezorRepo @Inject constructor( Logger.debug("Scanning for reconnect devices", context = TAG) val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } val knownDevice = knownDevices.find { it.matches(deviceId) } - val device = resolveKnownReconnectDevice(deviceId, knownDevice, allowBleFallback) + val scannedDevices = trezorService.scan() + Logger.debug( + "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", + context = TAG, + ) + // Honor the transport the user selected — connect to exactly the + // entry they tapped instead of overriding Bluetooth with USB. + val device = scannedDevices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() + ?: throw AppError("Device not found nearby — is it powered on?") Logger.debug("Found reconnect device '${device.id}'", context = TAG) 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 }.onFailure { e -> Logger.error("Connect known device failed", e, context = TAG) _state.update { it.copy(error = e.message) } - if (!forceSession) { - disconnectStaleSession(deviceId) - } } } finally { if (startedConnecting) { @@ -692,96 +698,11 @@ class TrezorRepo @Inject constructor( } suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { - connectedFeatures(deviceId)?.let { return@withContext Result.success(it) } - if (isConnectInProgress()) { - awaitInFlightConnect(deviceId) - connectedFeatures(deviceId)?.let { return@withContext Result.success(it) } - } - if (isKnownBluetoothDevice(deviceId)) { - return@withContext reconnectKnownBluetoothDevice(deviceId) - } - connectKnownDevice(deviceId, forceSession = true) - } - - /** - * BLE Trezors often need a few seconds to advertise again after unlock, so retry - * with growing delays (same cadence as [retryAutoReconnect]) instead of failing on - * the first empty scan or a premature direct-address connect. - */ - private suspend fun reconnectKnownBluetoothDevice(deviceId: String): Result { - var lastFailure: Throwable? = null - repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> - if (attempt > 0) { - delay(TRANSPORT_RESTORED_RECONNECT_DELAY * attempt) - } - connectedFeatures(deviceId)?.let { return Result.success(it) } - if (isConnectInProgress()) { - awaitInFlightConnect(deviceId) - connectedFeatures(deviceId)?.let { return Result.success(it) } - } - val allowBleFallback = attempt == TRANSPORT_RESTORED_MAX_ATTEMPTS - 1 - val result = connectKnownDevice( - deviceId = deviceId, - forceSession = attempt == 0, - allowBleFallback = allowBleFallback, - ) - if (result.isSuccess) return result - val failure = result.exceptionOrNull() - if (failure?.isTrezorUserCancellation() == true) { - return Result.failure(failure) - } - lastFailure = failure - } - return Result.failure(lastFailure ?: AppError("Failed to connect")) - } - - suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = withContext(ioDispatcher) { - (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } - .any { it.matches(deviceId) && it.transportType == TransportType.BLUETOOTH } - } - - fun deriveWalletId(xpubs: Map, deviceId: String = ""): String = - deriveHardwareWalletId(xpubs, deviceId) - - private suspend fun connectedFeatures(deviceId: String): TrezorFeatures? { val current = _state.value.connected - return if (current?.id == deviceId && trezorService.isConnected()) current.features else null - } - - private suspend fun awaitInFlightConnect(deviceId: String) { - transportReconnectJob?.takeIf { it.isActive }?.join() - waitForConnectAttempt(deviceId) - } - - private suspend fun waitForConnectAttempt(deviceId: String) { - try { - withTimeout(CONNECT_ATTEMPT_MAX_WAIT) { - while (true) { - if (connectedFeatures(deviceId) != null) return@withTimeout - if (!isConnectInProgress()) return@withTimeout - delay(CONNECT_ATTEMPT_POLL_INTERVAL) - } - } - } catch (e: TimeoutCancellationException) { - // Expected: in-flight connect ran longer than our wait window; caller will retry. - } - } - - private suspend fun resolveKnownReconnectDevice( - deviceId: String, - knownDevice: KnownDevice?, - allowBleFallback: Boolean = true, - ): TrezorDeviceInfo { - val scannedDevices = trezorService.scan() - Logger.debug( - "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", - context = TAG, - ) - scannedDevices.find { it.id == deviceId }?.let { return it } - if (allowBleFallback) { - knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo()?.let { return it } + if (current?.id == deviceId && trezorService.isConnected()) { + return@withContext Result.success(current.features) } - throw AppError("Device not found nearby — is it powered on?") + connectKnownDevice(deviceId, forceSession = false, refreshKnownDevice = false) } suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { @@ -823,12 +744,12 @@ 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), - walletId: String, ): Result = withContext(ioDispatcher) { runCatching { awaitSetup() @@ -850,6 +771,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() @@ -923,21 +850,6 @@ class TrezorRepo @Inject constructor( } } - /** Pre-connects one known BLE Trezor before the transfer sign screen asks for it. */ - fun warmUpKnownDevice(deviceId: String) { - scope.launch { - if (connectedFeatures(deviceId) != null) return@launch - if (isConnectInProgress()) return@launch - if (!hasKnownDevice(deviceId)) return@launch - if (!isKnownBluetoothDevice(deviceId)) return@launch - - Logger.info("Warming up known bluetooth device '$deviceId'", context = TAG) - ensureConnected(deviceId).onFailure { - Logger.debug("Warm up connect failed for '$deviceId'", context = TAG) - } - } - } - /** * Serializes reconnect triggers into one in-flight retry loop. A Trezor * re-enumerates USB during its unlock flow, so a single replug delivers several @@ -974,7 +886,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 } @@ -989,7 +901,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) @@ -1009,7 +921,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) @@ -1018,9 +930,10 @@ class TrezorRepo @Inject constructor( } 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 @@ -1110,10 +1023,6 @@ class TrezorRepo @Inject constructor( } suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { - val connectedId = _state.value.connected?.id - if (connectedId != null && connectedId != deviceId) { - return@withContext Result.success(Unit) - } val result = runSuspendCatching { trezorService.disconnect() disconnectTransportDevice(deviceId) @@ -1187,41 +1096,7 @@ data class ConnectedTrezorDevice( private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId -private val KnownDevice.walletKey: String - get() = walletKey(xpubs, id) - -private fun walletKey(xpubs: Map, fallback: String): String = - xpubs.values.sorted().joinToString().ifEmpty { fallback } - -private fun deriveHardwareWalletId(xpubs: Map, deviceId: String = ""): String = - runCatching { HwWalletId.derive(xpubs) }.getOrElse { - if (xpubs.isNotEmpty()) walletKey(xpubs, deviceId) else newHardwareWalletId() - } - -private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { - val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: deriveHardwareWalletId(xpubs, deviceId) -} - -private fun newHardwareWalletId(): String = runCatching { - HwWalletId.derive(mapOf("fallback" to java.util.UUID.randomUUID().toString())) -}.getOrElse { java.util.UUID.randomUUID().toString() } - -private fun List.withHardwareWalletIds(): 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) { - deriveHardwareWalletId(it.xpubs, it.id) - } - if (it.walletId == walletId) it else it.copy(walletId = walletId) - } -} +private const val DEFAULT_GAP_LIMIT = 20u private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index c5540516b..631aad029 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,12 +82,16 @@ 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.WalletScope import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.msatFloorOf @@ -243,13 +248,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { - private val walletId = WalletScope.default + 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 = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -257,18 +262,21 @@ class ActivityService( minDate = null, maxDate = null, limit = null, - sortDirection = null, + sortDirection = null ) for (activity in activities) { - val id = when (activity) { - is Activity.Lightning -> activity.v1.id - is Activity.Onchain -> activity.v1.id + when (activity) { + is Activity.Lightning -> deleteActivityById(activity.v1.walletId, activity.v1.id) + is Activity.Onchain -> deleteActivityById(activity.v1.walletId, activity.v1.id) } - deleteActivityById(walletId = walletId, activityId = id) } } } + suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + deleteActivitiesByWalletId(walletId) + } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { insertActivity(activity) } @@ -281,9 +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( @@ -312,16 +325,22 @@ class ActivityService( ) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(walletId = walletId, txId = 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(walletId = walletId, activityId = 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(walletId = walletId, 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 { @@ -334,6 +353,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -343,40 +363,39 @@ class ActivityService( limit: UInt? = null, sortDirection: SortDirection? = null, ): List = ServiceQueue.CORE.background { - getActivities( - walletId = walletId, - filter = filter, - txType = txType, - tags = tags, - search = search, - minDate = minDate, - maxDate = maxDate, - limit = limit, - sortDirection = sortDirection, - ) + getActivities(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { - updateActivity(activityId = id, activity = activity) + updateActivity(id, activity) } - suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(walletId = walletId, activityId = 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(walletId = walletId, activityId = toActivityId, tags = tags) + addTags(walletId ?: defaultWalletId, toActivityId, tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(walletId = walletId, activityId = fromActivityId, tags = 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(walletId = walletId, activityId = 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() @@ -404,22 +423,22 @@ class ActivityService( suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.addPreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, - tags = tags, + tags = tags ) } suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.removePreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, tags = tags, ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = defaultWalletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( @@ -427,14 +446,14 @@ class ActivityService( searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { com.synonym.bitkitcore.getPreActivityMetadata( - walletId = walletId, + walletId = defaultWalletId, searchKey = searchKey, searchByAddress = searchByAddress, ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = defaultWalletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -537,7 +556,7 @@ class ActivityService( return } - val existingActivity = getActivityById(walletId = walletId, activityId = 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 && @@ -577,8 +596,8 @@ class ActivityService( ) } - if (getActivityById(walletId = walletId, activityId = payment.id) != null) { - updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) + if (getActivityById(defaultWalletId, payment.id) != null) { + updateActivity(payment.id, Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) } @@ -917,24 +936,19 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(walletId = walletId, activityId = 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, @@ -946,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) { @@ -983,12 +1009,26 @@ class ActivityService( if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 - updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) + updateActivity(existingOnchain.id, Activity.Onchain(onChain)) } else { upsertActivity(Activity.Onchain(onChain)) } } + 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 @@ -1101,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, @@ -1124,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) @@ -1230,7 +1272,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) + updateActivity(replacedActivity.id, Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { Logger.info( @@ -1290,7 +1332,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) + updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1334,7 +1376,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1354,7 +1396,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } @@ -1416,44 +1458,52 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(walletId = walletId, activityId = 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(walletId = walletId, activityId = 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 = activityId, activity = updatedActivity) - Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) + updateActivity(activityId, updatedActivity) + 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 = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -1471,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/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2b1642a37..a5765c87a 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 80d8c507f..8cc2dead5 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/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 6c9f0f831..7566a78ba 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 @@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult @@ -31,7 +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.HwWalletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork @@ -69,7 +68,6 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( balance = event.balance, - activities = event.activities.toImmutableList(), transactionCount = event.txCount, blockHeight = event.blockHeight, accountType = event.accountType, @@ -713,15 +711,13 @@ class TrezorViewModel @Inject constructor( ) ) } - val walletId = runCatching { HwWalletId.derive(mapOf("watcher" to key)) } - .getOrDefault("trezor:watcher") val result = trezorRepo.startWatcher( watcherId = watcherId, + walletId = ActivityWalletType.TREZOR.idPrefixed(watcherId), extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, accountType = state.watcher.selectedAccountType, - walletId = walletId, ) if (result.isSuccess) { @@ -780,7 +776,6 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - activities = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -960,9 +955,6 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherActivities: ImmutableList - get() = watcher.activities - val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1039,7 +1031,6 @@ data class TrezorWatcherState( val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, - val activities: 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/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 9b1d45116..9f3c8de04 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 cc3ea5f20..083121d0c 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 45efea812..368aabc41 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 2cedaeafb..dbb8f3aaa 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/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 45f2469ae..898540711 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 @@ -164,7 +165,6 @@ fun ActivityExploreScreen( val toastMessage = stringResource(R.string.common__copied) ActivityExploreContent( item = item, - isHardware = uiState.isHardwareActivity, txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> @@ -188,7 +188,6 @@ fun ActivityExploreScreen( @Composable private fun ActivityExploreContent( item: Activity, - isHardware: Boolean = false, txDetails: TransactionDetails? = null, boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, @@ -212,7 +211,7 @@ private fun ActivityExploreContent( showBitcoinSymbol = false, modifier = Modifier.weight(1f), ) - ActivityIcon(activity = item, size = 48.dp, isHardware = isHardware) + ActivityIcon(activity = item, size = 48.dp) } Spacer(modifier = Modifier.height(16.dp)) @@ -221,7 +220,6 @@ private fun ActivityExploreContent( is Activity.Onchain -> { OnchainDetails( onchain = item, - isHardware = isHardware, onCopy = onCopy, txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, @@ -284,7 +282,6 @@ private fun LightningDetails( @Composable private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, - isHardware: Boolean, onCopy: (String) -> Unit, txDetails: TransactionDetails?, boostTxDoesExist: Map = emptyMap(), @@ -307,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, + ) } } }, @@ -321,13 +321,16 @@ 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, + ) } } }, ) - } else if (!isHardware && !onchain.v1.isTransfer) { + } else { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier @@ -343,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 @@ -393,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(), ) } } @@ -415,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 e05492d00..a596ab12a 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/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 6c4394e81..a5fe61114 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.rawTimestamp +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.rawTimestamp().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 5af0ba317..b20bb4c06 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 a26a32481..501732239 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dd7217c83..ba38aafbb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -85,7 +85,6 @@ import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.isFixedAmount -import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat @@ -96,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 @@ -335,6 +335,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2442,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 } @@ -2460,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) @@ -2484,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) @@ -2877,12 +2879,10 @@ class AppViewModel @Inject constructor( } fun toast(error: Throwable) { - if (error.isTrezorUserCancellation()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = error.message?.takeIf { it.isNotBlank() } - ?: context.getString(R.string.common__error_body), + description = error.message ?: context.getString(R.string.common__error_body) ) } 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 1c935765f..2402f8bd3 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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c4df1976d..16c7f8c30 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.idPrefixed("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()) } From dffb34b06f3d3ce25bbece7e5794deebdb06a75a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 1 Jul 2026 14:57:58 +0200 Subject: [PATCH 06/17] fix: update core activity integration --- .../to/bitkit/repositories/LightningRepo.kt | 4 +- .../activity/components/ActivityIcon.kt | 178 +++--------------- .../wallets/activity/utils/PreviewItems.kt | 165 +++++++++------- .../wallets/send/SendCoinSelectionScreen.kt | 7 +- .../send/SendCoinSelectionViewModel.kt | 19 +- .../bitkit/ui/sheets/BoostTransactionSheet.kt | 8 +- .../ui/sheets/BoostTransactionViewModel.kt | 26 ++- .../sheets/BoostTransactionViewModelTest.kt | 50 +++-- 8 files changed, 181 insertions(+), 276 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1175d9fca..f073dea15 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 @@ -88,7 +89,6 @@ import to.bitkit.services.LnurlWithdrawResponse import to.bitkit.services.LspNotificationsService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.UrlValidator @@ -1179,7 +1179,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( - walletId = WalletScope.default, + walletId = DEFAULT_WALLET_ID, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, 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 1127c8c76..f0f1af349 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/utils/PreviewItems.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt index c74fb3c9a..b0d137680 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 ffd725a7f..b39498f40 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 0d771fb13..e6fc3a800 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,21 +73,15 @@ 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() } } } - .onFailure { - Logger.error("Failed to load tags for utxo $txId", it, context = TAG) - } } } } 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 c9fe07801..199c6e6ec 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 e814bb10f..cbc036c3e 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'", log = false) + } + } + } + + private fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) this.activity = activity @@ -346,8 +366,8 @@ class BoostTransactionViewModel @Inject constructor( return Result.success(Unit) } - private fun handleError(message: String, error: Throwable? = null) { - Logger.error(message, error, context = TAG) + private fun handleError(message: String, error: Throwable? = null, log: Boolean = true) { + if (log) Logger.error(message, error, context = TAG) _uiState.update { it.copy( boosting = false, 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 59f38021c..f46086093 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 @@ -57,7 +58,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { private val totalFee = 1000UL private val testValue = 50000UL - private val onchainActivity = OnchainActivity.create(walletId = "wallet0", + private val onchainActivity = OnchainActivity.create( id = "test_id", txType = PaymentType.SENT, txId = mockTxId, @@ -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) } } From bea419c9ca34b5fafde1d796fa04c6567da10092 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 5 Jul 2026 13:23:36 +0200 Subject: [PATCH 07/17] fix: delegate wallet ids to core --- app/src/main/java/to/bitkit/services/TrezorService.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9..535b316b3 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()) + } } From 89fdabd1967ff744629af54907b65726cdd01cb3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 5 Jul 2026 14:38:48 +0200 Subject: [PATCH 08/17] test: handle hw transfer intro --- journeys/hardware-wallet/detail-overview.xml | 2 +- journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml | 3 +++ journeys/hardware-wallet/transfer-to-spending-node-warmup.xml | 3 +++ journeys/hardware-wallet/transfer-to-spending.xml | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index 8d13bda3c..3b14b0c90 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -17,7 +17,7 @@ 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 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 a36e8485d..05fbaedeb 100644 --- a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -19,6 +19,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 diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml index 518131da3..8466f6640 100644 --- a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -21,6 +21,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 4c628a7b4..3d28b2567 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -18,6 +18,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", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad From e030394ff469dbf6c48123aaeccfa5069d15a9b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Jul 2026 12:54:48 +0200 Subject: [PATCH 09/17] fix: stabilize hw activities --- .../to/bitkit/repositories/TransferRepo.kt | 20 +++++- .../bitkit/services/TrezorBridgeTransport.kt | 13 ++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 33 +++------- .../services/TrezorBridgeTransportTest.kt | 31 +++++++++ .../viewmodels/TransferViewModelTest.kt | 64 ++++--------------- journeys/hardware-wallet/README.md | 53 ++++++++++++++- .../hardware-wallet/activity-blue-icons.xml | 26 ++++---- .../activity-detail-hw-tags.xml | 20 +++--- journeys/hardware-wallet/detail-overview.xml | 9 ++- .../transfer-to-spending-max-lsp-cap.xml | 29 ++++++++- .../transfer-to-spending-node-warmup.xml | 6 +- .../hardware-wallet/transfer-to-spending.xml | 46 +++++++++++-- 12 files changed, 232 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 449a8f56c..7c00fc748 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 @@ -107,6 +108,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() }) { @@ -120,6 +122,7 @@ class TransferRepo @Inject constructor( feeRate = feeRate, isTransfer = true, channelId = order.channel?.shortChannelId, + walletId = walletId, ) }.onFailure { Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) @@ -260,7 +263,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/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 58d55d103..bfd49160b 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -44,11 +44,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 } @@ -151,7 +148,13 @@ 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 { diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 884a0cb10..3e52a0f02 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -32,8 +32,8 @@ 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.ext.isTrezorUserCancellation import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast @@ -276,6 +276,7 @@ class TransferViewModel @Inject constructor( createTransferActivity: Boolean = false, fee: ULong = 0uL, feeRate: ULong = 0uL, + walletId: String = DEFAULT_WALLET_ID, txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, ) { @@ -294,6 +295,7 @@ class TransferViewModel @Inject constructor( txId = txId, fee = fee, feeRate = feeRate, + walletId = walletId, ) } viewModelScope.launch { walletRepo.syncBalances() } @@ -524,10 +526,6 @@ class TransferViewModel @Inject constructor( } /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ - fun warmUpHardwareConnection(deviceId: String) { - hwWalletRepo.warmUpKnownDevice(deviceId) - } - fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { if (hwTransferSignJob?.isActive == true) return @@ -539,6 +537,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 -> @@ -548,6 +550,7 @@ class TransferViewModel @Inject constructor( createTransferActivity = true, fee = result.miningFeeSats, feeRate = result.feeRate, + walletId = walletId, ) setTransferEffect(TransferEffect.OnHwTxSigned) } @@ -588,7 +591,6 @@ class TransferViewModel @Inject constructor( } }.getOrElse { if (it is CancellationException && it !is TimeoutCancellationException) throw it - if (it.isTrezorUserCancellation()) throw it throw HardwareReconnectError(it) } } @@ -639,12 +641,8 @@ class TransferViewModel @Inject constructor( private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { when (e) { is HardwareReconnectError -> { - if (e.isTrezorUserCancellation()) { - Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) - return - } Logger.error("Failed to reconnect hardware device", e, context = TAG) - showHardwareReconnectError(deviceId) + showHardwareReconnectError() } is HardwareSigningTimeoutError -> { Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) @@ -655,24 +653,13 @@ class TransferViewModel @Inject constructor( showHardwareFundingError(e) } else -> { - if (e.isTrezorUserCancellation()) { - Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) - return - } Logger.error("Hardware transfer failed", e, context = TAG) ToastEventBus.send(e) } } } - private suspend fun showHardwareReconnectError(deviceId: String) { - if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.hardware__connect_error), - ) - return - } + private suspend fun showHardwareReconnectError() { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 254089fb7..69dd58e60 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/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f0087e153..b503cdf59 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -5,14 +5,12 @@ import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions -import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.withTimeout import org.junit.Before @@ -26,7 +24,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -37,7 +34,6 @@ import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet -import to.bitkit.models.Toast import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.models.safe @@ -50,11 +46,9 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals -import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -223,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)) @@ -255,7 +251,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) } @@ -277,6 +275,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())) @@ -300,9 +300,10 @@ 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"))) - whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -325,6 +326,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)) @@ -339,52 +342,6 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } - @Test - fun `onTransferToSpendingHwConfirm does not fund order when user cancels on device`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) - .thenReturn(Result.success(mock())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())) - .thenReturn(Result.failure(TrezorException.UserCancelled())) - - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) - advanceUntilIdle() - - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm does not toast when user cancels during reconnect`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) - .thenReturn(Result.failure(TrezorException.UserCancelled())) - whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)).thenReturn("reconnect title") - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)).thenReturn("reconnect body") - - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) - advanceUntilIdle() - toastJob.cancel() - - assertTrue(toasts.isEmpty()) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - } - private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", @@ -418,6 +375,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/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 158019896..c5b247fdf 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -67,7 +67,7 @@ Remove step forgets the device. | `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 | @@ -102,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 34b0032ab..f5fd12b1e 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 in the home list and the All Activity screen - with blue icon variants and tab filter behavior. Hardware activities are now first-class - Bitkit Core activities (persisted by the watcher), so they appear in the unified list and - survive tab and tag filters like normal transactions. Requires a paired Bridge emulator - whose wallet has at least one on-chain transaction (run connect-home-tile.xml first; fund - per README.md if the deterministic wallet has no history). + 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 - Tap back to the "All" tab and verify the blue-icon hardware items are listed again + 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 index c457a124c..9fbda2fe1 100644 --- a/journeys/hardware-wallet/activity-detail-hw-tags.xml +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -3,20 +3,22 @@ Verifies that a hardware-wallet transaction behaves as a first-class Bitkit Core activity: its detail screen supports tags (which persist and keep the item visible under a tag filter) and its Explore screen shows the transaction inputs and outputs fetched from the - configured Electrum backend. Requires a paired Bridge emulator whose wallet has at least - one on-chain transaction (run connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). Use a hardware seed distinct from the Bitkit wallet - seed so the transaction resolves as a hardware (blue-icon) activity, not a local one. + 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 the first activity item with a blue (hardware) circular icon + Tap "Show All" beneath the activity list - Verify an activity detail screen opens showing a blue icon and an on-chain amount + 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 @@ -25,16 +27,16 @@ Verify a tag chip labelled "hwtest" is shown on the activity detail screen - Navigate back to the home screen, then tap the same blue-icon activity again and verify the "hwtest" tag is still shown (it persisted to Bitkit Core) + 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 the home screen, then tap "Show All" beneath the activity list + Navigate back to All Activity - Open the tag filter, select the "hwtest" tag, and verify the blue-icon hardware activity remains listed in the filtered results + 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 3b14b0c90..8296b52f0 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. @@ -20,7 +22,10 @@ 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 05fbaedeb..e3c6892a8 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 @@ -41,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 8466f6640..e2ee7e3e3 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 diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 3d28b2567..c2681f585 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -1,20 +1,31 @@ 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") @@ -40,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" From 500c0ba49369f1dca434178b8bea759a4ccc7cc7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Jul 2026 20:51:05 +0200 Subject: [PATCH 10/17] refactor: extract hw wallet identity --- .../java/to/bitkit/models/HwWalletIdentity.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 app/src/main/java/to/bitkit/models/HwWalletIdentity.kt diff --git a/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt new file mode 100644 index 000000000..094dc5ce0 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt @@ -0,0 +1,110 @@ +package to.bitkit.models + +import java.math.BigInteger +import java.security.MessageDigest + +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 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()) + +val KnownDevice.hwWalletIdentityKey: String + get() = hwWalletIdentityKey(xpubs, id) + +fun hwWalletIdentityKey(xpubs: Map, fallback: String): String = + xpubs.values.sorted().joinToString().ifEmpty { fallback } + +fun List.findHardwareWalletId( + deviceId: String, + xpubs: Map, + deriveWalletId: (Collection) -> String, +): String { + val identityKey = hwWalletIdentityKey(xpubs, deviceId) + firstOrNull { it.hwWalletIdentityKey == identityKey }?.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() +} + +fun List.withHardwareWalletIds( + deriveWalletId: (Collection) -> String, +): List { + val existingByWallet = filter { it.walletId.isNotBlank() } + .associate { it.hwWalletIdentityKey to it.walletId } + val generatedByWallet = mutableMapOf() + + return map { + val walletId = existingByWallet[it.hwWalletIdentityKey] + ?: generatedByWallet.getOrPut(it.hwWalletIdentityKey) { deriveWalletId(it.xpubs.values) } + if (it.walletId == walletId) it else it.copy(walletId = walletId) + } +} + +fun List.withStandardExtendedKeys(): List = map { device -> + val normalized = device.xpubs.mapValues { it.value.toStandardExtendedKey() } + if (normalized == device.xpubs) device else device.copy(xpubs = normalized) +} + +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)) +} From 199b0f55bbbfd84386b72df91ebdebc695b3e7b8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 12:53:34 +0200 Subject: [PATCH 11/17] refactor: trim pr noise --- .github/workflows/ci.yml | 4 ++-- .github/workflows/e2e.yml | 18 +++++++++--------- .github/workflows/e2e_migration.yml | 8 ++++---- .github/workflows/lint.yml | 4 ++-- .github/workflows/release-internal.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/ui-tests.yml | 4 ++-- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f1e8b836..476a65f4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -57,7 +57,7 @@ jobs: run: ./gradlew testDevDebugUnitTest - name: Upload test report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: unit_test_report_${{ github.run_number }} path: app/build/reports/tests/testDevDebugUnitTest/ diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dd62321ac..26388e7de 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: code: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.code == 'true' }} steps: - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 if: github.event_name == 'pull_request' id: filter with: @@ -51,7 +51,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "adopt" + distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -83,7 +83,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK (local) - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -101,7 +101,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "adopt" + distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -136,7 +136,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK (regtest) - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk-regtest_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -210,7 +210,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -302,7 +302,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.shard.name }}) if: failure() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: e2e-artifacts_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ @@ -368,7 +368,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK (regtest) - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: bitkit-e2e-apk-regtest_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -454,7 +454,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.shard.name }}) if: failure() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: e2e-artifacts-regtest_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 96b671036..0a969ea9c 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "adopt" + distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -64,7 +64,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -138,7 +138,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: bitkit-e2e-apk_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -222,7 +222,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: failure() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: e2e-artifacts_${{ matrix.scenario.name }}_${{ matrix.rn_version }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 54a64a062..48d6783c0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -48,7 +48,7 @@ jobs: continue-on-error: true - name: Upload lint report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: lint_report_${{ github.run_number }} path: app/build/reports/detekt/ diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index dd7ec9416..db98ee9ad 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -149,7 +149,7 @@ jobs: echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" - name: Upload internal artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.artifacts.outputs.artifact_name }} path: ${{ steps.artifacts.outputs.artifact_dir }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7af68520..f71485ddb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -177,7 +177,7 @@ jobs: echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" - name: Upload release artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.artifacts.outputs.artifact_name }} path: ${{ steps.artifacts.outputs.artifact_dir }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 935be2829..0a07a6ec8 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' - name: Cache gradle uses: actions/cache@v5 @@ -107,7 +107,7 @@ jobs: - name: Upload UI test report if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: compose_test_report path: | From f2daba9f8ab064670fa71eb5685c4a711fbd2498 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 13:57:04 +0200 Subject: [PATCH 12/17] refactor: trim stacked diff --- .github/workflows/ci.yml | 4 +- .github/workflows/e2e.yml | 18 +- .github/workflows/e2e_migration.yml | 8 +- .github/workflows/lint.yml | 4 +- .github/workflows/release-internal.yml | 4 +- .github/workflows/release.yml | 4 +- .github/workflows/ui-tests.yml | 4 +- app/src/main/java/to/bitkit/ext/Activities.kt | 10 - .../activity/components/ActivityIcon.kt | 178 +++++++++++++++--- .../components/ActivityListGrouped.kt | 82 ++++---- .../activity/components/ActivityRow.kt | 82 ++++---- .../wallets/activity/utils/PreviewItems.kt | 165 +++++++--------- 12 files changed, 325 insertions(+), 238 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 476a65f4f..8f1e8b836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'temurin' + distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -57,7 +57,7 @@ jobs: run: ./gradlew testDevDebugUnitTest - name: Upload test report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: unit_test_report_${{ github.run_number }} path: app/build/reports/tests/testDevDebugUnitTest/ diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26388e7de..dd62321ac 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: code: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.code == 'true' }} steps: - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v4 + - uses: dorny/paths-filter@v3 if: github.event_name == 'pull_request' id: filter with: @@ -51,7 +51,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "temurin" + distribution: "adopt" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -83,7 +83,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK (local) - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: bitkit-e2e-apk_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -101,7 +101,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "temurin" + distribution: "adopt" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -136,7 +136,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK (regtest) - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: bitkit-e2e-apk-regtest_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -210,7 +210,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v6 with: name: bitkit-e2e-apk_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -302,7 +302,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.shard.name }}) if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: e2e-artifacts_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ @@ -368,7 +368,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK (regtest) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v6 with: name: bitkit-e2e-apk-regtest_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -454,7 +454,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.shard.name }}) if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: e2e-artifacts-regtest_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 0a969ea9c..96b671036 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: "17" - distribution: "temurin" + distribution: "adopt" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -64,7 +64,7 @@ jobs: mv "$apk" app/build/outputs/apk/dev/debug/bitkit_e2e.apk - name: Upload APK - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: bitkit-e2e-apk_${{ github.run_number }} path: app/build/outputs/apk/dev/debug/bitkit_e2e.apk @@ -138,7 +138,7 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Download APK - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v6 with: name: bitkit-e2e-apk_${{ github.run_number }} path: bitkit-e2e-tests/aut @@ -222,7 +222,7 @@ jobs: - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: e2e-artifacts_${{ matrix.scenario.name }}_${{ matrix.rn_version }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 48d6783c0..54a64a062 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'temurin' + distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -48,7 +48,7 @@ jobs: continue-on-error: true - name: Upload lint report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: lint_report_${{ github.run_number }} path: app/build/reports/detekt/ diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index db98ee9ad..dd7ec9416 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'temurin' + distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -149,7 +149,7 @@ jobs: echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" - name: Upload internal artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: ${{ steps.artifacts.outputs.artifact_name }} path: ${{ steps.artifacts.outputs.artifact_dir }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f71485ddb..b7af68520 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'temurin' + distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 @@ -177,7 +177,7 @@ jobs: echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: ${{ steps.artifacts.outputs.artifact_name }} path: ${{ steps.artifacts.outputs.artifact_dir }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 0a07a6ec8..935be2829 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '17' - distribution: 'temurin' + distribution: 'adopt' - name: Cache gradle uses: actions/cache@v5 @@ -107,7 +107,7 @@ jobs: - name: Upload UI test report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: compose_test_report path: | diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 960c847e5..a71c16b2c 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -109,11 +109,6 @@ 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 @@ -137,11 +132,6 @@ fun Activity.timestamp() = when (this) { } } -fun Activity.rawTimestamp() = when (this) { - is Activity.Lightning -> v1.timestamp - is Activity.Onchain -> v1.timestamp -} - enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") 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 f0f1af349..1127c8c76 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,9 +19,12 @@ 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 @@ -29,7 +32,6 @@ 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 @@ -42,17 +44,14 @@ 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( - id = if (txType == PaymentType.SENT) { - R.drawable.ic_sent - } else { - R.drawable.ic_received - }, - ) + val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { - isCpfpChild || activity.isBoosting() -> { + isCpfpChild || isBoosting -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -68,30 +67,21 @@ fun ActivityIcon( testTag = "ActivityContactAvatar", modifier = 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, - ) + isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) + else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) } } @Composable private fun ActivityIconOnchain( - txType: PaymentType, - isTransfer: Boolean, - doesExist: Boolean, + activity: Activity, arrowIcon: Painter, size: Dp, isHardware: Boolean, modifier: Modifier = Modifier, ) { - val isTransferFromSpending = isTransfer && txType == PaymentType.RECEIVED + val isTransfer = activity.isTransfer() + val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED val (iconColor, backgroundColor) = when { isHardware -> Colors.Blue to Colors.Blue16 isTransferFromSpending -> Colors.Purple to Colors.Purple16 @@ -105,7 +95,7 @@ private fun ActivityIconOnchain( CircularIcon( icon = when { - !doesExist -> painterResource(R.drawable.ic_x) + !activity.doesExist() -> painterResource(R.drawable.ic_x) isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, @@ -187,7 +177,145 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - previewActivityItems.forEach { ActivityIcon(activity = it) } + // 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", + ) + ) + ) } } } 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 a5fe61114..00bb2e9cb 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,7 +13,6 @@ 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 @@ -30,7 +29,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.activityKey -import to.bitkit.ext.rawTimestamp import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM @@ -81,17 +79,18 @@ fun ActivityListGrouped( ) { itemsIndexed( items = groupedItems, - key = { _, item -> + key = { index, item -> when (item) { - is ActivityGroupHeader -> "header_${item.title}" - is ActivityGroupEntry -> item.item.activityKey() + is String -> "header_$item" + is Activity -> item.activityKey() + else -> "item_$index" } } ) { index, item -> when (item) { - is ActivityGroupHeader -> { + is String -> { Caption13Up( - text = item.title, + text = item, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -104,8 +103,7 @@ fun ActivityListGrouped( ) } - is ActivityGroupEntry -> { - val activity = item.item + is Activity -> { Column( modifier = Modifier .animateItem( @@ -115,12 +113,12 @@ fun ActivityListGrouped( ) ) { ActivityRow( - item = activity, + item = item, onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", - title = titleProvider(activity) ?: contactActivityTitle(activity, contacts), - isHardware = activity.scopedId() in hardwareIds, - contact = if (showContactAvatar) contactForActivity(activity, contacts) else null, + title = titleProvider(item) ?: contactActivityTitle(item, contacts), + isHardware = item.scopedId() in hardwareIds, + contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) } @@ -175,17 +173,18 @@ fun LazyListScope.activityListGroupedItems( val groupedItems = groupActivityItems(items) itemsIndexed( items = groupedItems, - key = { _, item -> + key = { index, item -> when (item) { - is ActivityGroupHeader -> "header_${item.title}" - is ActivityGroupEntry -> item.item.activityKey() + is String -> "header_$item" + is Activity -> item.activityKey() + else -> "item_$index" } }, ) { index, item -> when (item) { - is ActivityGroupHeader -> { + is String -> { Caption13Up( - text = item.title, + text = item, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -198,8 +197,7 @@ fun LazyListScope.activityListGroupedItems( ) } - is ActivityGroupEntry -> { - val activity = item.item + is Activity -> { Column( modifier = Modifier .animateItem( @@ -209,10 +207,10 @@ fun LazyListScope.activityListGroupedItems( ) ) { ActivityRow( - item = activity, + item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = activity.scopedId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } @@ -259,7 +257,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) @@ -279,7 +277,10 @@ private fun groupActivityItems(activityItems: List): List() for (item in activityItems) { - val timestamp = item.rawTimestamp().toLong() + val timestamp = when (item) { + is Activity.Lightning -> item.v1.timestamp.toLong() + is Activity.Onchain -> item.v1.timestamp.toLong() + } when { timestamp >= startOfDay -> todayItems.add(item) timestamp >= startOfYesterday -> yesterdayItems.add(item) @@ -292,42 +293,33 @@ private fun groupActivityItems(activityItems: List): List item.v1.status + is Activity.Onchain -> null + } val isLightning = item is Activity.Lightning val timestamp = item.timestamp() - val txType = item.txType() + val txType: PaymentType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" - val confirmed = item.confirmed() + val confirmed: Boolean? = when (item) { + is Activity.Lightning -> null + is Activity.Onchain -> item.v1.confirmed + } val isTransfer = item.isTransfer() - val txId = item.txId() val activityListViewModel = activityListViewModel var isCpfpChild by remember { mutableStateOf(false) } val resolvedTitle = title.takeIf { @@ -101,9 +100,9 @@ fun ActivityRow( shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } - LaunchedEffect(txId) { - isCpfpChild = if (txId != null && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(txId) + LaunchedEffect(item) { + isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(item.v1.txId) } else { false } @@ -139,36 +138,37 @@ fun ActivityRow( title = resolvedTitle, ) val context = LocalContext.current - val subtitleText = if (isLightning) { - item.message().ifEmpty { formattedTime(timestamp) } - } else { - when { - !item.doesExist() -> stringResource(R.string.wallet__activity_removed) + 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) - isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) - 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_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_savings_done) - } else { - val duration = context.getFeeShortDescription(item.feeRate(), feeRates) - stringResource(R.string.wallet__activity_transfer_savings_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) + } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> formattedTime(timestamp) - else -> { - val feeDescription = context.getFeeShortDescription(item.feeRate(), feeRates) - stringResource(R.string.wallet__activity_confirms_in) - .replace("{feeRateDescription}", feeDescription) + else -> { + val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) + } } } } @@ -194,9 +194,9 @@ private fun shouldUseContactActivityTitle( ): Boolean { if (isTransfer || isCpfpChild) return false - return when { - activity is Activity.Lightning -> status == PaymentState.SUCCEEDED - else -> activity.doesExist() + return when (activity) { + is Activity.Lightning -> status == PaymentState.SUCCEEDED + is Activity.Onchain -> activity.v1.doesExist } } 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 b0d137680..c74fb3c9a 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,7 +8,6 @@ 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 { @@ -20,119 +19,97 @@ val previewActivityItems: ImmutableList = buildList { fun Calendar.epochSecond() = (timeInMillis / 1000).toULong() + // Today add( - onchainPreviewItem( - id = "1", - txType = PaymentType.RECEIVED, - value = 42_000uL, - fee = 200uL, - timestamp = today.epochSecond(), - txId = "01", - confirmed = true, - isBoosted = true, - doesExist = false, + 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(), + ) ) ) + // Yesterday add( - 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", + 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", + ) ) ) + // This Week add( - lightningPreviewItem( - id = "3", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 217_000uL, - fee = 17uL, - timestamp = thisWeek.epochSecond(), + 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", + ) ) ) + // This Month add( - onchainPreviewItem( - id = "4", - txType = PaymentType.SENT, - value = 950_000uL, - fee = 110uL, - timestamp = thisMonth.epochSecond(), - txId = "04", - isTransfer = true, + 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", + ) ) ) + // Last Year add( - lightningPreviewItem( - id = "5", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 200_000uL, - fee = 1uL, - timestamp = lastYear.epochSecond(), + Activity.Lightning( + LightningActivity.create( + id = "5", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 200_000_u, + invoice = "lnbc…", + timestamp = lastYear.epochSecond(), + fee = 1_u, + ) ) ) }.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, - ) -) From cdc7e6b18a09525b6aa9055e77378f0a24135062 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 14:03:59 +0200 Subject: [PATCH 13/17] fix: resolve stacked rebase fallout --- .../ui/screens/trezor/TrezorPreviewData.kt | 31 ------------ .../ui/screens/trezor/WatcherSection.kt | 48 ------------------- .../to/bitkit/viewmodels/TransferViewModel.kt | 7 +++ 3 files changed, 7 insertions(+), 79 deletions(-) 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 745d48b33..3ef067d6e 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 @@ -4,11 +4,9 @@ import com.synonym.bitkitcore.AccountAddresses import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AccountUtxo -import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeAccount import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction -import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorAddressResponse @@ -20,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.ext.create import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType @@ -313,33 +310,6 @@ internal object TrezorPreviewData { ), ) - val sampleWatcherActivities = listOf( - Activity.Onchain( - com.synonym.bitkitcore.OnchainActivity.create(walletId = "wallet0", - id = SAMPLE_TXID, - txType = PaymentType.RECEIVED, - txId = SAMPLE_TXID, - value = 100_000uL, - fee = 0uL, - address = SAMPLE_ADDRESS, - timestamp = 1_700_000_000uL, - confirmed = true, - ), - ), - Activity.Onchain( - com.synonym.bitkitcore.OnchainActivity.create(walletId = "wallet0", - id = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", - txType = PaymentType.SENT, - txId = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", - value = 48_800uL, - fee = 1_200uL, - address = SAMPLE_ADDRESS, - timestamp = 1_700_100_000uL, - confirmed = true, - ), - ), - ) - val uiStateWithActiveWatcher = TrezorUiState( network = TrezorNetworkState(selectedNetwork = BitkitCoreNetwork.REGTEST), watcher = TrezorWatcherState( @@ -347,7 +317,6 @@ internal object TrezorPreviewData { activeWatcherId = "watcher-abc-123", connectionStatus = WatcherConnectionStatus.CONNECTED, balance = sampleWalletBalance, - activities = sampleWatcherActivities.toImmutableList(), transactionCount = 2u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, 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 e0f1b9a26..2ef0e12ce 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,15 +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.Activity -import com.synonym.bitkitcore.PaymentType 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 @@ -172,51 +169,6 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { } } - if (uiState.watcherActivities.isNotEmpty()) { - VerticalSpacer(12.dp) - Caption13Up( - text = "Activities (${uiState.watcherActivities.size})", - color = Colors.White64, - ) - VerticalSpacer(4.dp) - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), - ) { - items(uiState.watcherActivities.filterIsInstance()) { activity -> - val onchain = activity.v1 - val directionLabel = when (onchain.txType) { - PaymentType.SENT -> "Sent" - PaymentType.RECEIVED -> "Recv" - } - val directionColor = when (onchain.txType) { - PaymentType.SENT -> Colors.Red - PaymentType.RECEIVED -> Colors.Green - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Caption( - text = "$directionLabel ${onchain.value} sats", - color = directionColor, - ) - HorizontalSpacer(8.dp) - Caption( - text = "${onchain.txId.take(8)}...${onchain.txId.takeLast(8)}", - color = Colors.White50, - ) - HorizontalSpacer(8.dp) - Caption( - text = if (onchain.confirmed) "confirmed" else "pending", - color = Colors.White50, - ) - } - } - } - } - if (uiState.watcherEvents.isNotEmpty()) { VerticalSpacer(12.dp) Caption13Up( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 3e52a0f02..b1770fa4e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -525,6 +525,13 @@ class TransferViewModel @Inject constructor( } } + fun warmUpHardwareConnection(deviceId: String) { + viewModelScope.launch { + hwWalletRepo.ensureConnected(deviceId) + .onFailure { Logger.warn("Failed to warm up hardware connection for '$deviceId'", it, context = TAG) } + } + } + /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { if (hwTransferSignJob?.isActive == true) return From 794722a642aaf20e14ad2c120ac755a42205fcc4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 14:12:46 +0200 Subject: [PATCH 14/17] fix: clean stacked lint fallout --- .../repositories/PreActivityMetadataRepo.kt | 2 +- .../java/to/bitkit/repositories/WalletRepo.kt | 2 +- .../to/bitkit/services/TrezorTransport.kt | 125 +++++++++++++++--- .../ui/screens/trezor/TrezorPreviewData.kt | 1 - .../to/bitkit/viewmodels/TransferViewModel.kt | 1 + .../to/bitkit/ext/BackupRestoreCompatTest.kt | 4 +- .../to/bitkit/ext/TrezorExceptionExtTest.kt | 2 +- 7 files changed, 110 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index a5c60d01b..4b09c1c6e 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -10,8 +10,8 @@ import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp -import to.bitkit.services.CoreService import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index f606cc39d..5a3b5c11e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -35,6 +35,7 @@ import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toBalance @@ -44,7 +45,6 @@ import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.measured diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 6a7d2672e..460781ebf 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -674,13 +674,17 @@ class TrezorTransport @Inject constructor( return UsbEndpoints(read = readEndpoint, write = writeEndpoint) } - @Suppress("TooGenericExceptionCaught", "ReturnCount") + @Suppress("TooGenericExceptionCaught", "ReturnCount", "LongMethod") private fun openUsbDevice(path: String): TrezorTransportWriteResult { return try { closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Device not found: $path", + errorCode = null, + ) if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { @@ -701,12 +705,20 @@ class TrezorTransport @Inject constructor( } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Failed to open device: $path", + errorCode = null, + ) val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to claim interface", + errorCode = null, + ) } val endpoints = findUsbEndpoints(usbInterface) @@ -730,7 +742,11 @@ class TrezorTransport @Inject constructor( TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB open failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportWriteResult( + success = false, + error = e.message ?: "Unknown error", + errorCode = null, + ) } } @@ -746,7 +762,11 @@ class TrezorTransport @Inject constructor( TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportWriteResult( + success = false, + error = e.message ?: "Unknown error", + errorCode = null, + ) } } @@ -782,10 +802,20 @@ class TrezorTransport @Inject constructor( } Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "", errorCode = null) + TrezorTransportReadResult( + success = true, + data = buffer.copyOf(bytesRead), + error = "", + errorCode = null, + ) } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Unknown error", + errorCode = null, + ) } } @@ -793,7 +823,11 @@ 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", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Device not open: $path", + errorCode = null, + ) val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -802,14 +836,22 @@ class TrezorTransport @Inject constructor( WRITE_TIMEOUT_MS, ) if (bytesWritten != data.size) { - return TrezorTransportWriteResult(success = false, error = "USB write timed out", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "USB write timed out", + errorCode = null, + ) } Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportWriteResult( + success = false, + error = e.message ?: "Unknown error", + errorCode = null, + ) } } @@ -875,18 +917,30 @@ 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", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to initiate bonding", + errorCode = null, + ) } 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", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Bonding failed or rejected", + errorCode = null, + ) } } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding timeout", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Bonding timeout", + errorCode = null, + ) } Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { @@ -1005,10 +1059,18 @@ 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(), errorCode = null) + TrezorTransportWriteResult( + success = timeoutError == null, + error = timeoutError.orEmpty(), + errorCode = null, + ) } catch (e: Exception) { Logger.error("BLE close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed", errorCode = null) + TrezorTransportWriteResult( + success = false, + error = e.message ?: "BLE close failed", + errorCode = null, + ) } finally { userInitiatedCloseSet.remove(path) } @@ -1037,7 +1099,12 @@ class TrezorTransport @Inject constructor( TrezorTransportReadResult(success = true, data = data, error = "", errorCode = null) } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Read failed", + errorCode = null, + ) } } @@ -1052,14 +1119,26 @@ 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", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Device not open: $path", + errorCode = null, + ) val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Write characteristic not available", + errorCode = null, + ) if (!connection.isConnected) { Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) - return TrezorTransportWriteResult(success = false, error = "Device disconnected", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Device disconnected", + errorCode = null, + ) } return try { @@ -1127,7 +1206,11 @@ class TrezorTransport @Inject constructor( TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } catch (e: Exception) { Logger.error("BLE write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed", errorCode = null) + TrezorTransportWriteResult( + success = false, + error = e.message ?: "Write failed", + errorCode = null, + ) } } 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 3ef067d6e..28bb3ae6b 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 to.bitkit.ext.create import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index b1770fa4e..39a3861e4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -201,6 +201,7 @@ class TransferViewModel @Inject constructor( } /** Pays for the order and start watching it for state updates */ + @Suppress("LongMethod") fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) { viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() diff --git a/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt b/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt index b3816a197..9567890b9 100644 --- a/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt +++ b/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt @@ -1,20 +1,20 @@ package to.bitkit.ext -import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import org.junit.Test import to.bitkit.data.AppCacheData import to.bitkit.di.json import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.WalletScope import to.bitkit.test.BaseUnitTest -import org.junit.Test import kotlin.test.assertEquals class BackupRestoreCompatTest : BaseUnitTest() { diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index b0afdb27a..68dd4c00a 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -1,10 +1,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.TrezorException +import org.junit.Test import to.bitkit.utils.AppError import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.junit.Test class TrezorExceptionExtTest { @Test From fbdca1c12d4627f6505f51a3c7163116ffb3dccf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 16:05:43 +0200 Subject: [PATCH 15/17] refactor: trim activity noise --- app/src/main/java/to/bitkit/ext/Activities.kt | 30 ---- .../java/to/bitkit/repositories/TrezorRepo.kt | 2 - .../wallets/activity/ActivityDetailScreen.kt | 137 +++++++++--------- .../wallets/activity/ActivityExploreScreen.kt | 81 ++++------- .../components/ActivityListGrouped.kt | 7 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 1 - .../bitkit/repositories/HwWalletRepoTest.kt | 2 - 7 files changed, 103 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index a71c16b2c..9606b3a00 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -21,11 +21,6 @@ fun Activity.walletId(): String = when (this) { 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) { @@ -52,21 +47,6 @@ 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 @@ -109,16 +89,6 @@ fun Activity.paymentState(): PaymentState? = when (this) { is Activity.Onchain -> null } -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 diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 6c01ddb23..b46a16c30 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -671,8 +671,6 @@ class TrezorRepo @Inject constructor( "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", context = TAG, ) - // Honor the transport the user selected — connect to exactly the - // entry they tapped instead of overriding Bluetooth with USB. val device = scannedDevices.find { it.id == deviceId } ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() ?: throw AppError("Device not found nearby — is it powered on?") 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 b044bcfb0..f6d646b9d 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,25 +55,16 @@ 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 @@ -364,8 +355,14 @@ private fun ActivityDetailContent( val amountPrefix = if (isSent) "-" else "+" val timestamp = item.timestamp() - val paymentValue = item.value() - val baseFee = item.fee() + 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 isSelfSend = isSent && paymentValue == 0uL val channelId = (item as? Activity.Onchain)?.v1?.channelId val txId = (item as? Activity.Onchain)?.v1?.txId @@ -562,8 +559,8 @@ private fun ActivityDetailContent( ) // Note section for Lightning payments with message - if (item is Activity.Lightning && item.message().isNotEmpty()) { - val message = item.message() + if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { + val message = item.v1.message Column( modifier = Modifier .fillMaxWidth() @@ -668,11 +665,12 @@ private fun ActivityDetailContent( val hasCompletedBoost = when (item) { is Activity.Lightning -> false is Activity.Onchain -> { - if (item.isBoosted() && item.v1.boostTxIds.isNotEmpty()) { - if (item.txType() == PaymentType.SENT) { + val activity = item.v1 + if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { + if (activity.txType == PaymentType.SENT) { true } else { - item.v1.boostTxIds.any { boostTxDoesExist[it] == true } + activity.boostTxIds.any { boostTxDoesExist[it] == true } } } else { false @@ -858,7 +856,7 @@ private fun StatusSection( Row(verticalAlignment = Alignment.CenterVertically) { when (item) { is Activity.Lightning -> { - when (item.paymentState()) { + when (item.v1.status) { PaymentState.PENDING -> { StatusRow( painterResource(R.drawable.ic_hourglass_simple), @@ -882,8 +880,6 @@ private fun StatusSection( Colors.Purple, ) } - - null -> Unit } } @@ -894,29 +890,29 @@ private fun StatusSection( var statusText = stringResource(R.string.wallet__activity_confirming) var statusTestTag: String? = null - if (item.isTransfer()) { + if (item.v1.isTransfer) { val context = LocalContext.current - val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" } - if (item.isBoosted()) { + if (item.v1.isBoosted) { statusIcon = painterResource(R.drawable.ic_timer_alt) statusColor = Colors.Yellow statusText = stringResource(R.string.wallet__activity_boosting) statusTestTag = "StatusBoosting" } - if (item.confirmed() == true) { + if (item.v1.confirmed) { statusIcon = painterResource(R.drawable.ic_check_circle) statusColor = Colors.Green statusText = stringResource(R.string.wallet__activity_confirmed) statusTestTag = "StatusConfirmed" } - if (!item.doesExist()) { + if (!item.v1.doesExist) { statusIcon = painterResource(R.drawable.ic_x) statusColor = Colors.Red statusText = stringResource(R.string.wallet__activity_removed) @@ -989,7 +985,18 @@ private fun ZigzagDivider() { private fun PreviewLightningSent() { AppThemeSurface { ActivityDetailContent( - item = previewLightningDetailItem(), + 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.", + ) + ), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, @@ -1009,7 +1016,20 @@ private fun PreviewLightningSent() { private fun PreviewOnchain() { AppThemeSurface { ActivityDetailContent( - item = previewOnchainDetailItem(), + 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(), + ) + ), assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, @@ -1032,7 +1052,18 @@ private fun PreviewSheetSmallScreen() { modifier = Modifier.sheetHeight(), ) { ActivityDetailContent( - item = previewLightningDetailItem(), + 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.", + ) + ), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, @@ -1059,61 +1090,27 @@ private fun shouldEnableBoostButton( if (isHardware) return false if (item !is Activity.Onchain) return false - // Check all disable conditions - val shouldDisable = isCpfpChild || !item.doesExist() || item.confirmed() == true || - (item.isBoosted() && isBoostCompleted(item, boostTxDoesExist)) + val activity = item.v1 + + val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || + (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) if (shouldDisable) return false - // Enable if not a transfer and has value - return !item.isTransfer() && item.value() > 0uL + return !activity.isTransfer && activity.value > 0uL } @ReadOnlyComposable @Composable private fun isBoostCompleted( - activity: Activity.Onchain, + activity: OnchainActivity, boostTxDoesExist: ImmutableMap, ): Boolean { - if (activity.v1.boostTxIds.isEmpty()) return true + if (activity.boostTxIds.isEmpty()) return true - if (activity.txType() == PaymentType.SENT) { + if (activity.txType == PaymentType.SENT) { return true } else { - return activity.v1.boostTxIds.any { boostTxDoesExist[it] == true } + return activity.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 898540711..742015868 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,7 +46,6 @@ 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 @@ -304,11 +303,8 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.inputs.forEach { input -> - BodySSB( - text = "${input.txid}:${input.vout}", - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) + val text = "${input.txid}:${input.vout}" + BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } }, @@ -321,11 +317,8 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.outputs.forEach { output -> - BodySSB( - text = output.scriptpubkeyAddress ?: "", - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) + val address = output.scriptpubkeyAddress ?: "" + BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } }, @@ -346,7 +339,7 @@ private fun ColumnScope.OnchainDetails( val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { boostTxIds.forEachIndexed { index, boostedTxId -> - val isRbf = onchain.txType() == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) + val isRbf = onchain.v1.txType == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp @@ -396,7 +389,19 @@ private fun Section( private fun PreviewLightning() { AppThemeSurface { ActivityExploreContent( - item = previewLightningDetailItem(), + 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", + ), + ), ) } } @@ -406,42 +411,20 @@ private fun PreviewLightning() { private fun PreviewOnchain() { AppThemeSurface { ActivityExploreContent( - item = previewOnchainDetailItem(), + 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(), + ), + ), ) } } - -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/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 00bb2e9cb..77e086c37 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -28,7 +28,6 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.activityKey import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM @@ -82,7 +81,8 @@ fun ActivityListGrouped( key = { index, item -> when (item) { is String -> "header_$item" - is Activity -> item.activityKey() + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" else -> "item_$index" } } @@ -176,7 +176,8 @@ fun LazyListScope.activityListGroupedItems( key = { index, item -> when (item) { is String -> "header_$item" - is Activity -> item.activityKey() + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" else -> "item_$index" } }, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 39a3861e4..ca41eb56b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -533,7 +533,6 @@ class TransferViewModel @Inject constructor( } } - /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { if (hwTransferSignJob?.isActive == true) return diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 8a08eb132..1c7a6dd65 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -528,11 +528,9 @@ class HwWalletRepoTest : BaseUnitTest() { "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) - // Stop fails: the watcher data must survive so the balance is not silently wrong. settingsData.value = SettingsData(addressTypesToMonitor = emptyList()) assertEquals(100uL, sut.totalSats.value) - // Stop succeeds on a later sync: the watcher data is finally dropped. whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) settingsData.value = SettingsData(addressTypesToMonitor = listOf("taproot")) assertEquals(0uL, sut.totalSats.value) From 4bd10041ad5eb592428c5cd1d725380ea8cf9564 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 20:05:17 +0200 Subject: [PATCH 16/17] refactor: rename hw activity helper --- app/src/main/java/to/bitkit/ext/Activities.kt | 2 +- .../main/java/to/bitkit/viewmodels/ActivityListViewModel.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 9606b3a00..884c027bb 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -21,7 +21,7 @@ fun Activity.walletId(): String = when (this) { fun Activity.scopedId(): String = "${walletId()}:${rawId()}" -fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) +fun Activity.isFromHardwareWallet(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index e5239801e..763ac8840 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,7 +27,7 @@ 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.isFromHardwareWallet import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer import to.bitkit.ext.scopedId @@ -130,7 +130,7 @@ class ActivityListViewModel @Inject constructor( val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) _hardwareIds.update { - filtered.filter { it.isHardwareWalletActivity() }.map { it.scopedId() }.toImmutableSet() + filtered.filter { it.isFromHardwareWallet() }.map { it.scopedId() }.toImmutableSet() } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } From ea9a2520e9d0465d36aacf454da7531e8af73e5e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 7 Jul 2026 20:16:54 +0200 Subject: [PATCH 17/17] refactor: clean hw helpers --- .../java/to/bitkit/models/HwWalletIdentity.kt | 36 ++++++++----------- .../to/bitkit/repositories/ActivityRepo.kt | 4 +-- .../to/bitkit/repositories/HwWalletRepo.kt | 14 ++++---- .../java/to/bitkit/repositories/TrezorRepo.kt | 8 ++--- .../java/to/bitkit/services/CoreService.kt | 4 +-- app/src/main/java/to/bitkit/utils/Crypto.kt | 8 ++--- .../viewmodels/ActivityDetailViewModel.kt | 10 +++--- .../ActivityDetailViewModelTest.kt | 2 +- .../bitkit/repositories/ActivityRepoTest.kt | 16 ++++----- .../bitkit/repositories/HwWalletRepoTest.kt | 16 ++++----- 10 files changed, 56 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt index 094dc5ce0..ddd1ac67b 100644 --- a/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt +++ b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt @@ -1,45 +1,44 @@ package to.bitkit.models +import to.bitkit.utils.sha256d import java.math.BigInteger -import java.security.MessageDigest 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 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()) -val KnownDevice.hwWalletIdentityKey: String - get() = hwWalletIdentityKey(xpubs, id) +val KnownDevice.identityKey: String + get() = identityKey(xpubs, id) -fun hwWalletIdentityKey(xpubs: Map, fallback: String): String = +fun identityKey(xpubs: Map, fallback: String): String = xpubs.values.sorted().joinToString().ifEmpty { fallback } -fun List.findHardwareWalletId( +fun List.findWalletId( deviceId: String, xpubs: Map, deriveWalletId: (Collection) -> String, ): String { - val identityKey = hwWalletIdentityKey(xpubs, deviceId) - firstOrNull { it.hwWalletIdentityKey == identityKey }?.walletId?.takeIf { it.isNotBlank() }?.let { return it } + val targetIdentityKey = identityKey(xpubs, deviceId) + firstOrNull { it.identityKey == targetIdentityKey }?.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() } -fun List.withHardwareWalletIds( +fun List.withWalletIds( deriveWalletId: (Collection) -> String, ): List { - val existingByWallet = filter { it.walletId.isNotBlank() } - .associate { it.hwWalletIdentityKey to it.walletId } - val generatedByWallet = mutableMapOf() + val existingByIdentity = filter { it.walletId.isNotBlank() } + .associate { it.identityKey to it.walletId } + val generatedByIdentity = mutableMapOf() return map { - val walletId = existingByWallet[it.hwWalletIdentityKey] - ?: generatedByWallet.getOrPut(it.hwWalletIdentityKey) { deriveWalletId(it.xpubs.values) } + val walletId = existingByIdentity[it.identityKey] + ?: generatedByIdentity.getOrPut(it.identityKey) { deriveWalletId(it.xpubs.values) } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } @@ -69,7 +68,7 @@ private fun String.decodeBase58Check(): Result = runCatching { 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)) + check(sha256d(payload).copyOfRange(0, BASE58_CHECKSUM_SIZE).contentEquals(checksum)) payload } @@ -88,7 +87,7 @@ private fun String.decodeBase58(): ByteArray { } private fun ByteArray.encodeBase58Check(): String { - val checksum = sha256d().copyOfRange(0, BASE58_CHECKSUM_SIZE) + val checksum = sha256d(this).copyOfRange(0, BASE58_CHECKSUM_SIZE) return (this + checksum).encodeBase58() } @@ -103,8 +102,3 @@ private fun ByteArray.encodeBase58(): String { 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)) -} diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index a4e027851..e9422e3ef 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -214,7 +214,7 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun persistHardwareActivities( + suspend fun persistHardware( activities: List, transactionDetails: List, ): Result = withContext(bgDispatcher) { @@ -241,7 +241,7 @@ class ActivityRepo @Inject constructor( ) } - suspend fun deleteActivitiesForWallet(walletId: String): Result = withContext(bgDispatcher) { + suspend fun deleteForWallet(walletId: String): Result = withContext(bgDispatcher) { runCatching { val deleted = coreService.activity.deleteByWalletId(walletId) notifyActivitiesChanged() diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f70155d85..25df0b1d2 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -46,7 +46,7 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType -import to.bitkit.models.hwWalletIdentityKey +import to.bitkit.models.identityKey import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType @@ -166,7 +166,7 @@ class HwWalletRepo @Inject constructor( runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } - val groupIds = devices.filter { it.hwWalletIdentityKey == target.hwWalletIdentityKey }.map { it.id }.toSet() + val groupIds = devices.filter { it.identityKey == target.identityKey }.map { it.id }.toSet() val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" } @@ -255,7 +255,7 @@ class HwWalletRepo @Inject constructor( val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } val customLabel = label.trim().take(DEVICE_LABEL_MAX_LENGTH).ifEmpty { null } val updated = devices.map { - if (it.hwWalletIdentityKey == target.hwWalletIdentityKey) it.copy(customLabel = customLabel) else it + if (it.identityKey == target.identityKey) it.copy(customLabel = customLabel) else it } hwWalletStore.saveKnownDevices(updated) } @@ -275,7 +275,7 @@ class HwWalletRepo @Inject constructor( null -> setOf(deviceId) else -> knownDevices - .filter { it.hwWalletIdentityKey == target.hwWalletIdentityKey } + .filter { it.identityKey == target.identityKey } .map { it.id } .toSet() } @@ -292,7 +292,7 @@ class HwWalletRepo @Inject constructor( target?.walletId?.takeIf { it.isNotBlank() } }.fold( onSuccess = { - if (it == null) Result.success(Unit) else activityRepo.deleteActivitiesForWallet(it) + if (it == null) Result.success(Unit) else activityRepo.deleteForWallet(it) }, onFailure = { Result.failure(it) }, ).onFailure { @@ -310,7 +310,7 @@ class HwWalletRepo @Inject constructor( // identity, so group by them to show one wallet and count its balance once. data.knownDevices .filter { it.xpubs.isNotEmpty() } - .groupBy { it.hwWalletIdentityKey } + .groupBy { it.identityKey } .map { (_, devices) -> val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } @@ -367,7 +367,7 @@ class HwWalletRepo @Inject constructor( val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } - activityRepo.persistHardwareActivities(activities, transactionDetails) + activityRepo.persistHardware(activities, transactionDetails) .getOrElse { return@collect } emitReceivedTxs(previous, activities, updatedWatcherData) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index b46a16c30..c7ee4debb 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -58,14 +58,14 @@ 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.findHardwareWalletId +import to.bitkit.models.findWalletId import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString import to.bitkit.models.toStandardExtendedKey import to.bitkit.models.toTrezorCoinType -import to.bitkit.models.withHardwareWalletIds import to.bitkit.models.withStandardExtendedKeys +import to.bitkit.models.withWalletIds import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -899,7 +899,7 @@ class TrezorRepo @Inject constructor( lastConnectedAt = clock.nowMs(), xpubs = xpubs, customLabel = previous?.customLabel, - walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs, ::deriveWalletId), + walletId = knownDevices.findWalletId(deviceInfo.id, xpubs, ::deriveWalletId), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -930,7 +930,7 @@ class TrezorRepo @Inject constructor( private suspend fun loadKnownDevices(): List = runCatching { val stored = hwWalletStore.loadKnownDevices() val devices = stored.withStandardExtendedKeys() - val migrated = devices.withHardwareWalletIds(::deriveWalletId) + val migrated = devices.withWalletIds(::deriveWalletId) if (migrated != stored) { hwWalletStore.saveKnownDevices(migrated) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 631aad029..8f94a74a4 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -943,7 +943,7 @@ class ActivityService( } } if (existingActivity == null) { - existingActivity = findAnyOnchainActivityByTxId(kind.txid) + existingActivity = findAnyOnchainByTxId(kind.txid) } val existingOnchainActivity = existingActivity as? Activity.Onchain @@ -1015,7 +1015,7 @@ class ActivityService( } } - private fun findAnyOnchainActivityByTxId(txId: String): Activity.Onchain? { + private fun findAnyOnchainByTxId(txId: String): Activity.Onchain? { return getActivities( walletId = null, filter = ActivityFilter.ONCHAIN, diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index 30903e024..81e005600 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -30,6 +30,10 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton +fun sha256d(input: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").run { digest(digest(input)) } +} + @Suppress("SwallowedException", "MagicNumber", "TooGenericExceptionCaught") @Singleton class Crypto @Inject constructor() { @@ -225,10 +229,6 @@ class Crypto @Inject constructor() { val signature = byteArrayOf(recIdByte) + rBytes + sBytes return signature.toHex() } - - private fun sha256d(input: ByteArray): ByteArray { - return MessageDigest.getInstance("SHA-256").run { digest(digest(input)) } - } } sealed class CryptoError(message: String) : AppError(message) { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index faf84fe08..576d9d09f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -70,7 +70,7 @@ class ActivityDetailViewModel @Inject constructor( it.copy(activityLoadState = ActivityLoadState.Success(activity)) } loadTags() - observeActivityChanges(activityId, walletId) + observeChanges(activityId, walletId) } else { _uiState.update { it.copy( @@ -101,16 +101,16 @@ class ActivityDetailViewModel @Inject constructor( _tags.update { persistentListOf() } } - private fun observeActivityChanges(activityId: String, walletId: String?) { + private fun observeChanges(activityId: String, walletId: String?) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { activityRepo.activitiesChanged.collect { - reloadActivity(activityId, walletId) + reload(activityId, walletId) } } } - private suspend fun reloadActivity(activityId: String, walletId: String?) { + private suspend fun reload(activityId: String, walletId: String?) { activityRepo.getActivity(activityId, walletId) .onSuccess { updatedActivity -> if (updatedActivity != null) { @@ -168,7 +168,7 @@ class ActivityDetailViewModel @Inject constructor( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id, walletId) + reload(id, walletId) } } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index ff2691cf7..5dd89563e 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -220,7 +220,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { } @Test - fun `reloadActivity keeps last state on failure`() = test { + fun `reload keeps last state on failure`() = test { val activity = createTestActivity(ACTIVITY_ID) val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index b525164d5..692d8dd3d 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -292,7 +292,7 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `persistHardwareActivities upserts multiple activities and transaction details`() = test { + fun `persistHardware upserts multiple activities and transaction details`() = test { val activities = listOf( createOnchainActivity( id = "hw-received-id", @@ -333,7 +333,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.upsertList(activities) }.thenReturn(Unit) wheneverBlocking { coreService.activity.upsertTransactionDetailsList(details) }.thenReturn(Unit) - val result = sut.persistHardwareActivities(activities, details) + val result = sut.persistHardware(activities, details) assertTrue(result.isSuccess) verify(coreService.activity).upsertList(activities) @@ -341,7 +341,7 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `persistHardwareActivities preserves transfer metadata for existing hardware activity`() = test { + fun `persistHardware preserves transfer metadata for existing hardware activity`() = test { val incoming = createOnchainActivity( id = "hw-transfer-txid", txId = "hw-transfer-txid", @@ -376,15 +376,15 @@ class ActivityRepoTest : BaseUnitTest() { }.thenReturn(existing.v1) whenever { coreService.activity.upsertList(listOf(expected)) }.thenReturn(Unit) - val result = sut.persistHardwareActivities(listOf(incoming), emptyList()) + val result = sut.persistHardware(listOf(incoming), emptyList()) assertTrue(result.isSuccess) verify(coreService.activity).upsertList(listOf(expected)) } @Test - fun `persistHardwareActivities does nothing when both lists are empty`() = test { - val result = sut.persistHardwareActivities(emptyList(), emptyList()) + fun `persistHardware does nothing when both lists are empty`() = test { + val result = sut.persistHardware(emptyList(), emptyList()) assertTrue(result.isSuccess) verify(coreService.activity, never()).upsertList(any()) @@ -392,10 +392,10 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `deleteActivitiesForWallet delegates to core delete by wallet id`() = test { + fun `deleteForWallet delegates to core delete by wallet id`() = test { wheneverBlocking { coreService.activity.deleteByWalletId(hardwareWalletId) }.thenReturn(3u) - val result = sut.deleteActivitiesForWallet(hardwareWalletId) + val result = sut.deleteForWallet(hardwareWalletId) assertTrue(result.isSuccess) verify(coreService.activity).deleteByWalletId(hardwareWalletId) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 1c7a6dd65..feb7f40de 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -87,8 +87,8 @@ class HwWalletRepoTest : BaseUnitTest() { 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)) + whenever { activityRepo.persistHardware(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -172,7 +172,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(10_562_411uL, wallet.balanceSats) assertEquals(10_562_411uL, sut.totalSats.value) assertEquals(listOf("t1", "t2"), wallet.activities.map { (it as Activity.Onchain).v1.txId }) - verify(activityRepo).persistHardwareActivities(listOf(received, sent), details) + verify(activityRepo).persistHardware(listOf(received, sent), details) } @Test @@ -189,13 +189,13 @@ class HwWalletRepoTest : BaseUnitTest() { ) assertEquals(0uL, sut.totalSats.value) - verify(activityRepo, never()).persistHardwareActivities(listOf(activity), emptyList()) + verify(activityRepo, never()).persistHardware(listOf(activity), emptyList()) } @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()) } + whenever { activityRepo.persistHardware(listOf(activity), emptyList()) } .thenReturn(Result.failure(AppError("persist failed"))) val sut = createRepo() @@ -581,7 +581,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") - verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) + verify(activityRepo).deleteForWallet(trezorWalletId) } @Test @@ -589,7 +589,7 @@ class HwWalletRepoTest : BaseUnitTest() { 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) } + whenever { activityRepo.deleteForWallet(trezorWalletId) } .thenReturn(Result.failure(AppError("purge failed"))) val sut = createRepo() runCurrent() @@ -597,7 +597,7 @@ class HwWalletRepoTest : BaseUnitTest() { val result = sut.removeDevice("dev1") assertEquals(true, result.isFailure) - verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) + verify(activityRepo).deleteForWallet(trezorWalletId) } @Test