diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..f6527a5212 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,6 +5,7 @@ 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 fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id @@ -94,6 +95,7 @@ enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") fun LightningActivity.Companion.create( + walletId: String = WalletScope.default, id: String, txType: PaymentType, status: PaymentState, @@ -108,6 +110,7 @@ fun LightningActivity.Companion.create( updatedAt: ULong? = createdAt, seenAt: ULong? = null, ) = LightningActivity( + walletId = walletId, id = id, txType = txType, status = status, @@ -125,6 +128,7 @@ fun LightningActivity.Companion.create( @Suppress("LongParameterList") fun OnchainActivity.Companion.create( + walletId: String = WalletScope.default, id: String, txType: PaymentType, txId: String, @@ -146,6 +150,7 @@ fun OnchainActivity.Companion.create( updatedAt: ULong? = createdAt, seenAt: ULong? = null, ) = OnchainActivity( + walletId = walletId, id = id, txType = txType, txId = txId, diff --git a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt new file mode 100644 index 0000000000..ff508539a3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt @@ -0,0 +1,17 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorException + +fun Throwable.isTrezorUserCancellation(): Boolean { + var current: Throwable? = this + while (current != null) { + when (current) { + is TrezorException.UserCancelled, + is TrezorException.PinCancelled, + is TrezorException.PassphraseCancelled, + -> return true + } + current = current.cause + } + return false +} diff --git a/app/src/main/java/to/bitkit/models/HwWalletId.kt b/app/src/main/java/to/bitkit/models/HwWalletId.kt new file mode 100644 index 0000000000..83e06cee28 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWalletId.kt @@ -0,0 +1,10 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.deriveWalletId + +object HwWalletId { + fun derive(xpubs: Map, deviceType: String = "trezor"): String { + require(xpubs.isNotEmpty()) { "xpubs must not be empty" } + return deriveWalletId(deviceType = deviceType, xpubs = xpubs.values.toList()) + } +} diff --git a/app/src/main/java/to/bitkit/models/WalletScope.kt b/app/src/main/java/to/bitkit/models/WalletScope.kt new file mode 100644 index 0000000000..d0a3c7b16e --- /dev/null +++ b/app/src/main/java/to/bitkit/models/WalletScope.kt @@ -0,0 +1,14 @@ +package to.bitkit.models + +import androidx.annotation.VisibleForTesting +import com.synonym.bitkitcore.getDefaultWalletId + +object WalletScope { + @VisibleForTesting + internal var testOverride: String? = null + + val default: String + get() = testOverride ?: lazyDefault + + private val lazyDefault: String by lazy { getDefaultWalletId() } +} diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7dfb8f4d10..9de8bc1ba4 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher 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 @@ -653,7 +654,7 @@ class ActivityRepo @Inject constructor( val now = nowTimestamp().epochSecond.toULong() insertActivity( Activity.Lightning( - LightningActivity( + LightningActivity.create( id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f4992921c0..9c81312ff1 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -4,12 +4,10 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WatcherEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -38,6 +36,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.create +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HwFundingAccount @@ -107,6 +106,8 @@ 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) @@ -153,6 +154,8 @@ class HwWalletRepo @Inject constructor( suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(deviceId) + suspend fun getFundingAccount( deviceId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, @@ -225,7 +228,10 @@ class HwWalletRepo @Inject constructor( ).getOrThrow() } if (signed.isFailure) { - trezorRepo.disconnectStaleSession(deviceId) + val failure = signed.exceptionOrNull() + if (failure?.isTrezorUserCancellation() != true) { + trezorRepo.disconnectStaleSession(deviceId) + } } val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() HwFundingBroadcastResult( @@ -352,14 +358,11 @@ class HwWalletRepo @Inject constructor( trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect val previous = _watcherData.value[watcherId] - val activities = event.transactions - .map { it.toOnchainActivity(clock, previous?.activities.orEmpty()) } - .toImmutableList() + val activities = event.activities.toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - transactions = event.transactions.toImmutableList(), activities = activities, ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) @@ -367,7 +370,7 @@ class HwWalletRepo @Inject constructor( activities.filterIsInstance().forEach { activityRepo.syncHardwareOnchainActivity(it.v1) } - emitReceivedTxs(previous, event, updatedWatcherData) + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -378,21 +381,21 @@ class HwWalletRepo @Inject constructor( */ private suspend fun emitReceivedTxs( previous: HwWatcherData?, - event: WatcherEvent.TransactionsChanged, + activities: List, watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.map { it.rawId() }.toSet() + val knownTxIds = previous.activities.mapNotNull { activity -> + (activity as? Activity.Onchain)?.v1?.txId + }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - event.transactions - .filter { - it.direction == TxDirection.RECEIVED && - it.txid !in knownTxIds && - emittedReceivedTxIds.add(it.txid) - } - .forEach { - val sats = mergedActivities.findOnchain(it.txid)?.v1?.value ?: it.amount - _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = sats)) + 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)) } } @@ -421,7 +424,13 @@ class HwWalletRepo @Inject constructor( device.xpubs .filterKeys { it in watcherSettings.monitoredTypes } .map { (addressType, xpub) -> - WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl) + WatcherSpec( + deviceId = device.id, + addressType = addressType, + xpub = xpub, + electrumUrl = watcherSettings.electrumUrl, + walletId = device.walletId, + ) } }.distinctBy { it.addressType to it.xpub } val filteredIds = filtered.map { it.watcherId }.toSet() @@ -437,6 +446,7 @@ class HwWalletRepo @Inject constructor( network = Env.network.toCoreNetwork(), accountType = spec.addressType.toAddressType()?.toAccountType(), electrumUrl = spec.electrumUrl, + walletId = spec.walletId, ).onSuccess { activeWatchers += spec.watcherId activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl @@ -473,59 +483,14 @@ class HwWalletRepo @Inject constructor( } } - private fun HistoryTransaction.toOnchainActivity(clock: Clock, previousActivities: List): Activity { - val activityTimestamp = timestamp ?: previousActivities.findOnchain(txid)?.v1?.timestamp - ?: clock.now().epochSeconds.toULong() - return listOf(this).toOnchainActivity( - timestamp = activityTimestamp, - sourceActivities = previousActivities, - ) - } - private fun List.toMergedActivities(): List { - val sourceActivities = flatMap { it.activities } - return flatMap { it.transactions } - .groupBy { it.txid } - .values - .map { transactions -> - val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull() - ?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp - ?: 0uL - transactions.toOnchainActivity(timestamp, sourceActivities) + val byId = linkedMapOf() + sortedBy { "${it.deviceId}|${it.addressType}" } + .flatMap { it.activities } + .forEach { activity -> + byId[activity.rawId()] = activity } - } - - private fun List.toOnchainActivity( - timestamp: ULong, - sourceActivities: List, - ): Activity { - val first = first() - val received = fold(0uL) { acc, tx -> acc.safe() + tx.received.safe() } - val sent = fold(0uL) { acc, tx -> acc.safe() + tx.sent.safe() } - val fee = mapNotNull { it.fee }.maxOrNull() ?: 0uL - val type = when { - received > sent -> PaymentType.RECEIVED - else -> PaymentType.SENT - } - val value = when (type) { - PaymentType.RECEIVED -> received.safe() - sent.safe() - PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() - } - val confirmations = maxOf { it.confirmations } - val sourceActivity = sourceActivities.findOnchain(first.txid) - return Activity.Onchain( - OnchainActivity.create( - id = first.txid, - txType = type, - txId = first.txid, - value = value, - fee = fee, - address = "", - timestamp = timestamp, - confirmed = confirmations > 0u, - confirmTimestamp = sourceActivity?.v1?.confirmTimestamp, - ) - ) + return byId.values.toList() } private fun List.findOnchain(txid: String) = filterIsInstance() @@ -536,6 +501,7 @@ class HwWalletRepo @Inject constructor( val addressType: String, val xpub: String, val electrumUrl: String, + val walletId: String, ) { val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" } @@ -577,6 +543,5 @@ private data class HwWatcherData( val deviceId: String, val addressType: String, val balanceSats: ULong, - val transactions: ImmutableList, val activities: ImmutableList, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 127a992b06..1175d9fcad 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -88,6 +88,7 @@ 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 @@ -1178,6 +1179,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( + walletId = WalletScope.default, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index d0fa79b790..a5c60d01b5 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -11,6 +11,7 @@ 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.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -127,11 +128,13 @@ class PreActivityMetadataRepo @Inject constructor( feeRate: ULong? = null, isTransfer: Boolean = false, channelId: String? = null, + walletId: String = WalletScope.default, ): Result = withContext(ioDispatcher) { return@withContext runCatching { require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( + walletId = walletId, paymentId = id, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..cd4037314b 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -28,12 +28,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred 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 @@ -69,10 +72,11 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import java.util.UUID +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 @@ -98,6 +102,8 @@ 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()) @@ -641,8 +647,9 @@ class TrezorRepo @Inject constructor( suspend fun connectKnownDevice( deviceId: String, forceSession: Boolean = false, + allowBleFallback: Boolean = true, ): Result = withContext(ioDispatcher) { - if (_state.value.isConnecting) { + if (isConnectInProgress()) { return@withContext Result.failure(AppError("Connection already in progress")) } var startedConnecting = false @@ -661,16 +668,7 @@ 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 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?") + val device = resolveKnownReconnectDevice(deviceId, knownDevice, allowBleFallback) 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()) @@ -682,6 +680,7 @@ class TrezorRepo @Inject constructor( }.onFailure { e -> Logger.error("Connect known device failed", e, context = TAG) _state.update { it.copy(error = e.message) } + disconnectStaleSession(deviceId) } } finally { if (startedConnecting) { @@ -691,11 +690,89 @@ 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 + lastFailure = result.exceptionOrNull() + } + 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 } + } + + private suspend fun connectedFeatures(deviceId: String): TrezorFeatures? { val current = _state.value.connected - if (current?.id == deviceId && trezorService.isConnected()) { - return@withContext Result.success(current.features) + 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) { + runCatching { + withTimeout(CONNECT_ATTEMPT_MAX_WAIT) { + while (true) { + if (connectedFeatures(deviceId) != null) return@withTimeout + if (!isConnectInProgress()) return@withTimeout + delay(CONNECT_ATTEMPT_POLL_INTERVAL) + } + } + }.onFailure { + if (it is CancellationException && it !is TimeoutCancellationException) throw it } - connectKnownDevice(deviceId, forceSession = false) + } + + 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 } + } + throw AppError("Device not found nearby — is it powered on?") } suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { @@ -742,11 +819,13 @@ class TrezorRepo @Inject constructor( gapLimit: UInt = 20u, accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), + walletId: String, ): Result = withContext(ioDispatcher) { runCatching { awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -835,6 +914,21 @@ 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 @@ -1090,9 +1184,13 @@ private fun List.findHardwareWalletId(deviceId: String, xpubs: Map< val walletKey = walletKey(xpubs, deviceId) return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: newHardwareWalletId() + ?: runCatching { HwWalletId.derive(xpubs) }.getOrElse { newHardwareWalletId() } } +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 } @@ -1100,13 +1198,17 @@ private fun List.withHardwareWalletIds(): List { return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { + if (it.xpubs.isNotEmpty()) { + runCatching { HwWalletId.derive(it.xpubs) }.getOrElse { newHardwareWalletId() } + } else { + newHardwareWalletId() + } + } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() - private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, transportType = transportType.toCoreTransportType(), diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6c53356f5f..f606cc39dc 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -44,6 +44,7 @@ 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 @@ -236,6 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( + walletId = WalletScope.default, paymentId = paymentId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b11bddae8d..c5540516b9 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -86,6 +86,7 @@ import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid 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 @@ -242,10 +243,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + private val walletId = WalletScope.default + suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( + walletId = walletId, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -253,14 +257,14 @@ 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 } - deleteActivityById(activityId = id) + deleteActivityById(walletId = walletId, activityId = id) } } } @@ -300,6 +304,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -308,15 +313,15 @@ class ActivityService( } suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(txid) + getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) } suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { - getActivityById(id) + getActivityById(walletId = walletId, activityId = id) } suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { - getActivityByTxId(txId = txId) + getActivityByTxId(walletId = walletId, txId = txId) } suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { @@ -338,29 +343,39 @@ class ActivityService( limit: UInt? = null, sortDirection: SortDirection? = null, ): List = ServiceQueue.CORE.background { - getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + getActivities( + walletId = walletId, + filter = filter, + txType = txType, + tags = tags, + search = search, + minDate = minDate, + maxDate = maxDate, + limit = limit, + sortDirection = sortDirection, + ) } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { - updateActivity(id, activity) + updateActivity(activityId = id, activity = activity) } suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(id) + deleteActivityById(walletId = walletId, activityId = id) } suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { ServiceQueue.CORE.background { - addTags(toActivityId, tags) + addTags(walletId = walletId, activityId = toActivityId, tags = tags) } } suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(fromActivityId, tags) + removeTags(walletId = walletId, activityId = fromActivityId, tags = tags) } suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(forActivityId) + getTags(walletId = walletId, activityId = forActivityId) } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { @@ -388,26 +403,38 @@ class ActivityService( } suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.addPreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.addPreActivityMetadataTags( + walletId = walletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.removePreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.removePreActivityMetadataTags( + walletId = walletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = walletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( searchKey: String, searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { - com.synonym.bitkitcore.getPreActivityMetadata(searchKey = searchKey, searchByAddress = searchByAddress) + com.synonym.bitkitcore.getPreActivityMetadata( + walletId = walletId, + searchKey = searchKey, + searchByAddress = searchByAddress, + ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = walletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -510,7 +537,7 @@ class ActivityService( return } - val existingActivity = getActivityById(payment.id) + val existingActivity = getActivityById(walletId = walletId, activityId = payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -550,8 +577,8 @@ class ActivityService( ) } - if (getActivityById(payment.id) != null) { - updateActivity(payment.id, Activity.Lightning(ln)) + if (getActivityById(walletId = walletId, activityId = payment.id) != null) { + updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) } @@ -890,7 +917,7 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(payment.id) + var existingActivity = getActivityById(walletId = walletId, activityId = payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) @@ -956,7 +983,7 @@ class ActivityService( if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 - updateActivity(existingOnchain.id, Activity.Onchain(onChain)) + updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) } else { upsertActivity(Activity.Onchain(onChain)) } @@ -1203,7 +1230,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(replacedActivity.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { Logger.info( @@ -1263,7 +1290,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1307,7 +1334,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1327,7 +1354,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } @@ -1390,7 +1417,7 @@ class ActivityService( } suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: return@background false + val activity = getActivityById(walletId = walletId, activityId = activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null @@ -1398,7 +1425,7 @@ class ActivityService( } suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: run { + val activity = getActivityById(walletId = walletId, activityId = activityId) ?: run { Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) return@background } @@ -1409,7 +1436,7 @@ class ActivityService( is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp)) } - updateActivity(activityId, updatedActivity) + updateActivity(activityId = activityId, activity = updatedActivity) Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) } @@ -1426,6 +1453,7 @@ class ActivityService( suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { val timestamp = (System.currentTimeMillis() / 1000).toULong() val activities = getActivities( + walletId = walletId, filter = ActivityFilter.ALL, txType = null, tags = null, diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ba2b1ae635..3e66ef04df 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -54,6 +54,7 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.WalletScope import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.toSettingsString @@ -948,12 +949,20 @@ class MigrationService @Inject constructor( val onchain = activityRepo.getOnchainActivityByTxId(activityId) if (onchain != null) { applied++ - ActivityTags(activityId = onchain.id, tags = tagList) + ActivityTags( + walletId = WalletScope.default, + activityId = onchain.id, + tags = tagList, + ) } else { val activity = activityRepo.getActivity(activityId).getOrNull() if (activity != null) { applied++ - ActivityTags(activityId = activityId, tags = tagList) + ActivityTags( + walletId = WalletScope.default, + activityId = activityId, + tags = tagList, + ) } else { Logger.warn("Activity not found for tags: id=$activityId", context = TAG) null @@ -1005,6 +1014,7 @@ class MigrationService @Inject constructor( Activity.Lightning( LightningActivity( + walletId = WalletScope.default, id = item.id, txType = txType, status = status, @@ -1960,6 +1970,7 @@ class MigrationService @Inject constructor( val activityTimestamp = if (timestampSecs > 0u) timestampSecs else now val newOnchain = OnchainActivity( + walletId = WalletScope.default, id = item.id, txType = if (item.txType == "sent") PaymentType.SENT else PaymentType.RECEIVED, txId = txId, diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 023045080d..58d55d1034 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -97,24 +97,24 @@ class TrezorBridgeTransport( val session = json.decodeFromString(response).session openSessions[path] = session Logger.info("Opened Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) }.getOrElse { Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed") + TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed", errorCode = null) } } fun closeDevice(path: String): TrezorTransportWriteResult { val session = openSessions.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) return runCatching { post("/release/${encode(session)}") Logger.info("Closed Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) }.getOrElse { Logger.warn("Failed to close Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed") + TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed", errorCode = null) } } @@ -123,6 +123,7 @@ class TrezorBridgeTransport( success = false, data = byteArrayOf(), error = "Trezor Bridge uses callMessage for '$path'", + errorCode = null, ) } @@ -130,6 +131,7 @@ class TrezorBridgeTransport( return TrezorTransportWriteResult( success = false, error = "Trezor Bridge uses callMessage for '$path' and ignored '${data.size}' bytes", + errorCode = null, ) } @@ -144,6 +146,7 @@ class TrezorBridgeTransport( messageType = 0u.toUShort(), data = byteArrayOf(), error = "Trezor Bridge device not open: $path", + errorCode = null, ) return runCatching { @@ -158,6 +161,7 @@ class TrezorBridgeTransport( messageType = 0u.toUShort(), data = byteArrayOf(), error = it.message ?: "Bridge call failed", + errorCode = null, ) } } @@ -189,6 +193,7 @@ class TrezorBridgeTransport( messageType = messageType, data = bytes.copyOfRange(HEADER_SIZE, HEADER_SIZE + length), error = "", + errorCode = null, ) } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index cba4965b59..6a7d2672eb 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -680,7 +680,7 @@ class TrezorTransport @Inject constructor( closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { @@ -688,23 +688,25 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult( success = false, error = "USB permission missing for '$path'", + errorCode = null, ) } if (!requestUsbPermission(device)) { return TrezorTransportWriteResult( success = false, error = "USB permission denied for '$path'", + errorCode = null, ) } } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + ?: 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") + return TrezorTransportWriteResult(success = false, error = "Failed to claim interface", errorCode = null) } val endpoints = findUsbEndpoints(usbInterface) @@ -714,6 +716,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult( success = false, error = "Could not find required endpoints", + errorCode = null, ) } @@ -724,10 +727,10 @@ class TrezorTransport @Inject constructor( endpoints.write, ) Logger.info("USB device opened: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + 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") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -735,15 +738,15 @@ class TrezorTransport @Inject constructor( private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) openDevice.connection.releaseInterface(openDevice.usbInterface) openDevice.connection.close() Logger.info("USB device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + 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") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -755,6 +758,7 @@ class TrezorTransport @Inject constructor( success = false, data = byteArrayOf(), error = "Device not open: $path", + errorCode = null, ) // Synchronous transfer on purpose: the async UsbRequest API requires @@ -773,14 +777,15 @@ class TrezorTransport @Inject constructor( success = false, data = byteArrayOf(), error = "USB read timed out", + errorCode = null, ) } Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") + 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") + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error", errorCode = null) } } @@ -788,7 +793,7 @@ class TrezorTransport @Inject constructor( private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -797,14 +802,14 @@ class TrezorTransport @Inject constructor( WRITE_TIMEOUT_MS, ) if (bytesWritten != data.size) { - return TrezorTransportWriteResult(success = false, error = "USB write timed out") + 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 = "") + 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") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -870,18 +875,18 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + return 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") + return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected", errorCode = null) } } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + return TrezorTransportWriteResult(success = false, error = "Bonding timeout", errorCode = null) } Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { @@ -892,7 +897,7 @@ class TrezorTransport @Inject constructor( bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed") + return TrezorTransportWriteResult(success = false, error = "Bonding failed", errorCode = null) } } else { Logger.info("Device already bonded: '$address'", context = TAG) @@ -910,7 +915,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") } Logger.info("Reused open BLE device '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } val address = path.removePrefix("ble:") @@ -919,7 +924,7 @@ class TrezorTransport @Inject constructor( // fresh scan — a scan right after a disconnect often finds nothing yet. val device = discoveredBleDevices[address] ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } @@ -940,13 +945,13 @@ class TrezorTransport @Inject constructor( if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Connection timeout") + return TrezorTransportWriteResult(success = false, error = "Connection timeout", errorCode = null) } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Failed to connect") + return TrezorTransportWriteResult(success = false, error = "Failed to connect", errorCode = null) } gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) @@ -961,27 +966,27 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) Logger.info("BLE device opened: '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) connection.readQueue.clear() connection.writeLatch?.countDown() connection.connectionLatch?.countDown() Logger.info("Closed BLE device session '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) userInitiatedCloseSet.add(path) return try { @@ -1000,10 +1005,10 @@ class TrezorTransport @Inject constructor( connection.gatt.close() Thread.sleep(100) Logger.info("BLE device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) + 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") + TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed", errorCode = null) } finally { userInitiatedCloseSet.remove(path) } @@ -1015,7 +1020,8 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportReadResult( success = false, data = byteArrayOf(), - error = "Device not open: $path" + error = "Device not open: $path", + errorCode = null, ) return try { @@ -1023,14 +1029,15 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportReadResult( success = false, data = byteArrayOf(), - error = "Read timeout" + error = "Read timeout", + errorCode = null, ) Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") + 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") + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed", errorCode = null) } } @@ -1045,14 +1052,14 @@ class TrezorTransport @Inject constructor( @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + ?: 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") + return TrezorTransportWriteResult(success = false, error = "Device disconnected", errorCode = null) } return try { @@ -1079,7 +1086,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { @@ -1092,7 +1099,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { @@ -1106,7 +1113,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } Logger.debug("BLE wrote '${data.size}' bytes to '$path' (attempt '$attempt')", context = TAG) @@ -1114,13 +1121,13 @@ class TrezorTransport @Inject constructor( // Small delay between writes to avoid overwhelming the GATT Thread.sleep(BLE_WRITE_INTER_DELAY_MS) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } - TrezorTransportWriteResult(success = false, error = lastError) + 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") + TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed", errorCode = null) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index 5de144a582..25544379df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -55,6 +55,10 @@ fun SpendingHwSignScreen( return } + LaunchedEffect(deviceId) { + viewModel.warmUpHardwareConnection(deviceId) + } + LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 6a0bdac66b..745d48b339 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,9 +4,11 @@ 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 @@ -19,6 +21,7 @@ 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 import to.bitkit.repositories.ConnectedTrezorDevice @@ -36,6 +39,7 @@ internal object TrezorPreviewData { minorVersion = 8u, patchVersion = 1u, pinProtection = true, + unlocked = true, passphraseProtection = false, initialized = true, needsBackup = false, @@ -51,6 +55,7 @@ internal object TrezorPreviewData { minorVersion = null, patchVersion = null, pinProtection = null, + unlocked = null, passphraseProtection = null, initialized = null, needsBackup = null, @@ -257,6 +262,7 @@ internal object TrezorPreviewData { sent = 0uL, net = 100_000L, fee = null, + feeRate = null, amount = 100_000uL, direction = TxDirection.RECEIVED, blockHeight = 849_990u, @@ -269,6 +275,7 @@ internal object TrezorPreviewData { sent = 50_000uL, net = -50_000L, fee = 1_200uL, + feeRate = 5.0, amount = 48_800uL, direction = TxDirection.SENT, blockHeight = 849_995u, @@ -281,6 +288,7 @@ internal object TrezorPreviewData { sent = 5_000uL, net = 0L, fee = 500uL, + feeRate = 2.0, amount = 500uL, direction = TxDirection.SELF_TRANSFER, blockHeight = null, @@ -305,6 +313,33 @@ 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( @@ -312,7 +347,7 @@ internal object TrezorPreviewData { activeWatcherId = "watcher-abc-123", connectionStatus = WatcherConnectionStatus.CONNECTED, balance = sampleWalletBalance, - transactions = sampleHistoryTransactions.toImmutableList(), + activities = sampleWatcherActivities.toImmutableList(), transactionCount = 2u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index b4772d9785..b1f2f98640 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,6 +6,7 @@ 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,6 +32,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.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork @@ -68,7 +70,7 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( balance = event.balance, - transactions = event.transactions.toImmutableList(), + activities = event.activities.toImmutableList(), transactionCount = event.txCount, blockHeight = event.blockHeight, accountType = event.accountType, @@ -712,12 +714,15 @@ class TrezorViewModel @Inject constructor( ) ) } + val walletId = runCatching { HwWalletId.derive(mapOf("watcher" to key)) } + .getOrDefault("trezor:watcher") val result = trezorRepo.startWatcher( watcherId = watcherId, extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, accountType = state.watcher.selectedAccountType, + walletId = walletId, ) if (result.isSuccess) { @@ -776,7 +781,7 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - transactions = persistentListOf(), + activities = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -956,8 +961,8 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherTransactions: ImmutableList - get() = watcher.transactions + val watcherActivities: ImmutableList + get() = watcher.activities val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1035,7 +1040,7 @@ data class TrezorWatcherState( val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, - val transactions: ImmutableList = persistentListOf(), + 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/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index aeaabafc89..e0f1b9a266 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,7 +24,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.TxDirection +import 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 @@ -171,26 +172,25 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { } } - if (uiState.watcherTransactions.isNotEmpty()) { + if (uiState.watcherActivities.isNotEmpty()) { VerticalSpacer(12.dp) Caption13Up( - text = "Transactions (${uiState.watcherTransactions.size})", + text = "Activities (${uiState.watcherActivities.size})", color = Colors.White64, ) VerticalSpacer(4.dp) LazyColumn( modifier = Modifier.heightIn(max = 200.dp), ) { - items(uiState.watcherTransactions) { tx -> - val directionLabel = when (tx.direction) { - TxDirection.SENT -> "Sent" - TxDirection.RECEIVED -> "Recv" - TxDirection.SELF_TRANSFER -> "Self" + items(uiState.watcherActivities.filterIsInstance()) { activity -> + val onchain = activity.v1 + val directionLabel = when (onchain.txType) { + PaymentType.SENT -> "Sent" + PaymentType.RECEIVED -> "Recv" } - val directionColor = when (tx.direction) { - TxDirection.SENT -> Colors.Red - TxDirection.RECEIVED -> Colors.Green - TxDirection.SELF_TRANSFER -> Colors.White64 + val directionColor = when (onchain.txType) { + PaymentType.SENT -> Colors.Red + PaymentType.RECEIVED -> Colors.Green } Row( modifier = Modifier @@ -199,17 +199,17 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { horizontalArrangement = Arrangement.SpaceBetween, ) { Caption( - text = "$directionLabel ${tx.amount} sats", + text = "$directionLabel ${onchain.value} sats", color = directionColor, ) HorizontalSpacer(8.dp) Caption( - text = "${tx.txid.take(8)}...${tx.txid.takeLast(8)}", + text = "${onchain.txId.take(8)}...${onchain.txId.takeLast(8)}", color = Colors.White50, ) HorizontalSpacer(8.dp) Caption( - text = "${tx.confirmations} conf", + text = if (onchain.confirmed) "confirmed" else "pending", color = Colors.White50, ) } diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt index 4ce86166b4..6a69b9b4c0 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.shared.toast import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.models.Toast object ToastEventBus { @@ -23,11 +24,12 @@ object ToastEventBus { } suspend fun send(error: Throwable) { + if (error.isTrezorUserCancellation()) return _events.emit( Toast( type = Toast.ToastType.ERROR, title = "Error", - description = error.message ?: "Unknown error", + description = error.message?.takeIf { it.isNotBlank() } ?: "Unknown error", autoHide = true, visibilityTime = Toast.VISIBILITY_TIME_DEFAULT, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aa0d1ba2bf..dd7217c838 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -85,6 +85,7 @@ 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 @@ -2876,10 +2877,12 @@ 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 ?: context.getString(R.string.common__error_body) + description = error.message?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.common__error_body), ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 38116c390e..0753d82a99 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -33,6 +33,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults 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 @@ -494,6 +495,10 @@ 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 @@ -605,7 +610,7 @@ class TransferViewModel @Inject constructor( when (e) { is HardwareReconnectError -> { Logger.error("Failed to reconnect hardware device", e, context = TAG) - showHardwareReconnectError() + showHardwareReconnectError(deviceId) } is HardwareSigningTimeoutError -> { Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) @@ -616,13 +621,24 @@ 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() { + 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 + } 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/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt new file mode 100644 index 0000000000..b0afdb27a6 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorException +import to.bitkit.utils.AppError +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class TrezorExceptionExtTest { + @Test + fun `isTrezorUserCancellation returns true for user cancelled exceptions`() { + assertTrue(TrezorException.UserCancelled().isTrezorUserCancellation()) + assertTrue(TrezorException.PinCancelled().isTrezorUserCancellation()) + assertTrue(TrezorException.PassphraseCancelled().isTrezorUserCancellation()) + } + + @Test + fun `isTrezorUserCancellation walks the cause chain`() { + val error = AppError(TrezorException.UserCancelled()) + + assertTrue(error.isTrezorUserCancellation()) + } + + @Test + fun `isTrezorUserCancellation returns false for other errors`() { + assertFalse(AppError("sign failed").isTrezorUserCancellation()) + assertFalse(TrezorException.Timeout().isTrezorUserCancellation()) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d65386de54..caf7b134f4 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -67,7 +67,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { val hwActivity = Activity.Onchain( - OnchainActivity.create( + OnchainActivity.create(walletId = "wallet0", id = ACTIVITY_ID, txType = PaymentType.RECEIVED, txId = ACTIVITY_ID, @@ -263,7 +263,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { confirmed: Boolean = false, ): Activity.Onchain { return Activity.Onchain( - v1 = OnchainActivity.create( + v1 = OnchainActivity.create(walletId = "wallet0", 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 286fcdd677..e811196278 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -63,7 +63,7 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } - private val baseOnchainActivity = OnchainActivity.create( + private val baseOnchainActivity = OnchainActivity.create(walletId = "wallet0", id = "base_activity_id", txType = PaymentType.SENT, txId = "base_tx_id", @@ -279,7 +279,7 @@ class ActivityRepoTest : BaseUnitTest() { boostTxIds = listOf("boost-txid"), contact = "contact", ).v1 - val watcher = OnchainActivity.create( + val watcher = OnchainActivity.create(walletId = "wallet0", id = "transfer-txid", txType = PaymentType.SENT, txId = "transfer-txid", @@ -313,7 +313,7 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create( + val watcher = OnchainActivity.create(walletId = "wallet0", id = "hardware-only-txid", txType = PaymentType.RECEIVED, txId = "hardware-only-txid", diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 50cf626b1d..756ca5c736 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -3,11 +3,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -38,6 +38,7 @@ 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 @@ -145,7 +146,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 10_562_411uL), - transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + activities = listOf(watcherActivity(amount = 10_562_411uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, @@ -168,7 +170,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -177,7 +179,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -197,7 +199,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -206,7 +209,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -215,7 +219,7 @@ class HwWalletRepoTest : BaseUnitTest() { val activity = sut.wallets.value.single().activities.single() as Activity.Onchain assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) + assertEquals(50uL, activity.v1.value) assertEquals(150uL, sut.wallets.value.single().balanceSats) } @@ -234,7 +238,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -243,7 +248,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -253,26 +259,25 @@ class HwWalletRepoTest : BaseUnitTest() { 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) + assertEquals(50uL, activity.v1.value) } @Test - fun `preserves generated timestamp for pending tx refreshes`() = test { - whenever(clock.now()) - .thenReturn(Instant.fromEpochSeconds(1_800_000_000)) - .thenReturn(Instant.fromEpochSeconds(1_800_000_060)) + fun `preserves activity timestamp across watcher refreshes`() = test { val sut = createRepo() - val pendingTx = receivedTransaction(amount = 100uL).copy( + val pendingActivity = watcherActivity( + amount = 100uL, txid = "pending", blockHeight = null, - timestamp = null, + timestamp = 1_800_000_000uL, confirmations = 0u, ) watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), + activities = listOf(pendingActivity), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -283,7 +288,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), + activities = listOf(pendingActivity), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -313,9 +319,9 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -333,6 +339,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = eq(electrumServer), + walletId = any(), ) } @@ -358,6 +365,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = eq(secondServer), + walletId = any(), ) } @@ -367,12 +375,12 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -385,7 +393,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL)), + activities = listOf(watcherActivity(amount = 100uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -397,10 +406,11 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + activities = listOf( + watcherActivity(amount = 100uL), + watcherActivity(amount = 50uL, txid = "t2"), ), + transactionDetails = emptyList(), txCount = 2u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -412,10 +422,11 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + activities = listOf( + watcherActivity(amount = 100uL), + watcherActivity(amount = 50uL, txid = "t2"), ), + transactionDetails = emptyList(), txCount = 2u, blockHeight = 3u, accountType = AccountType.NATIVE_SEGWIT, @@ -435,7 +446,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -444,7 +455,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -454,7 +465,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -463,7 +475,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.TAPROOT, @@ -483,7 +496,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -492,9 +505,10 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 40uL), - transactions = listOf( - receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), + activities = listOf( + watcherActivity(amount = 60uL, txid = "t3", txType = PaymentType.SENT), ), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -514,13 +528,14 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) watcherEvents.emit( "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 421_900uL), - transactions = listOf(receivedTransaction(amount = 421_900uL)), + activities = listOf(watcherActivity(amount = 421_900uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -567,7 +582,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -596,7 +611,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -703,6 +718,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = any(), + walletId = any(), ) } @@ -724,6 +740,15 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).onAppForegrounded() } + @Test + fun `forwards warm up known device to the trezor repo`() = test { + val sut = createRepo() + + sut.warmUpKnownDevice("dev1") + + verify(trezorRepo).warmUpKnownDevice("dev1") + } + @Test fun `composeFundingTransaction returns composed fee data`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) @@ -836,6 +861,26 @@ 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() @@ -863,6 +908,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = any(), + walletId = any(), ) } @@ -875,19 +921,28 @@ class HwWalletRepoTest : BaseUnitTest() { total = total, ) - private fun receivedTransaction(amount: ULong) = HistoryTransaction( - txid = "t1", - received = amount, - sent = 0uL, - net = amount.toLong(), - fee = null, - amount = amount, - direction = TxDirection.RECEIVED, - blockHeight = 850_000u, - timestamp = 1_700_000_000uL, - confirmations = 3u, + 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, + ) = Activity.Onchain( + OnchainActivity.create( + walletId = "wallet0", + id = txid, + txType = txType, + txId = txid, + value = amount, + fee = 0uL, + 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())) @@ -974,6 +1029,7 @@ class HwWalletRepoTest : BaseUnitTest() { any(), anyOrNull(), any(), + any(), ) ) } diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index 139405fab3..d1c8409d49 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -45,7 +45,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { isReceive = false, feeRate = 10u, isTransfer = false, - channelId = "channel-123" + channelId = "channel-123", + walletId = "wallet0", ) @Before @@ -375,7 +376,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -393,7 +395,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = false, tags = emptyList(), - isTransfer = true + isTransfer = true, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -413,7 +416,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { tags = listOf("important"), feeRate = 10u, isTransfer = true, - channelId = "channel-123" + channelId = "channel-123", + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -430,7 +434,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = emptyList(), - isTransfer = false + isTransfer = false, + walletId = "wallet0", ) assertTrue(result.isFailure) @@ -455,7 +460,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - feeRate = null + feeRate = null, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -478,7 +484,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - channelId = null + channelId = null, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -496,7 +503,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) assertTrue(result.isFailure) @@ -592,7 +600,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) 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 039f52c708..f69cf75225 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -291,7 +291,7 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create( + val activity = OnchainActivity.create(walletId = "wallet0", id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -609,7 +609,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create( + val sweepActivity = OnchainActivity.create(walletId = "wallet0", id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = "sweep-txid", @@ -741,7 +741,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create( + val sweepActivity = OnchainActivity.create(walletId = "wallet0", 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 9c0dc0f612..3acc26ffef 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -95,7 +95,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) whenever(trezorTransport.disconnectDevice(any())).thenReturn( - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) @@ -467,6 +467,75 @@ 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() @@ -1124,6 +1193,7 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(features, result.getOrNull()) assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) verify(trezorService).connect(eq(bleDeviceId), any()) + verify(trezorService).scan() } @Test @@ -1163,6 +1233,36 @@ 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)) + } + // endregion // region clearError diff --git a/app/src/test/java/to/bitkit/test/BaseUnitTest.kt b/app/src/test/java/to/bitkit/test/BaseUnitTest.kt index ef07858b03..8b6650d871 100644 --- a/app/src/test/java/to/bitkit/test/BaseUnitTest.kt +++ b/app/src/test/java/to/bitkit/test/BaseUnitTest.kt @@ -5,7 +5,10 @@ import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before import org.junit.Rule +import to.bitkit.models.WalletScope @OptIn(ExperimentalCoroutinesApi::class) abstract class BaseUnitTest( @@ -16,5 +19,15 @@ abstract class BaseUnitTest( protected val testDispatcher get() = coroutinesTestRule.testDispatcher + @Before + fun setUpWalletScope() { + WalletScope.testOverride = "wallet0" + } + + @After + fun tearDownWalletScope() { + WalletScope.testOverride = null + } + protected fun test(block: suspend TestScope.() -> Unit) = runTest(testDispatcher) { block() } } diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index 66d1a9d944..fa1bd073a7 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -361,7 +361,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `startWatcher should not expose active watcher until start completes`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") @@ -391,13 +391,13 @@ class TrezorViewModelTest : BaseUnitTest() { sut.startWatcher() advanceUntilIdle() - verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any()) assertNull(sut.uiState.value.activeWatcherId) } @Test fun `watcher transaction event should mark watcher connected`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -406,7 +406,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = TrezorPreviewData.sampleWatcherActivities, + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -424,7 +425,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `watcher event should be handled while start is in flight`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -433,7 +434,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = TrezorPreviewData.sampleWatcherActivities, + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -458,7 +460,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") @@ -474,7 +476,7 @@ class TrezorViewModelTest : BaseUnitTest() { assertNull(state.activeWatcherId) assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) assertNull(state.watcherBalance) - assertTrue(state.watcherTransactions.isEmpty()) + assertTrue(state.watcherActivities.isEmpty()) } @Test diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 8b9e64ff12..59f38021c8 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -57,7 +57,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { private val totalFee = 1000UL private val testValue = 50000UL - private val onchainActivity = OnchainActivity.create( + private val onchainActivity = OnchainActivity.create(walletId = "wallet0", id = "test_id", txType = PaymentType.SENT, txId = mockTxId, diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..57e7fbb6f0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -121,7 +121,7 @@ class ActivityListViewModelTest : BaseUnitTest() { } private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( - OnchainActivity.create( + OnchainActivity.create(walletId = "wallet0", id = id, txType = txType, txId = id, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f6bc83f01e..97b3bb11a1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -5,6 +5,7 @@ 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 @@ -294,6 +295,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(RuntimeException("no device"))) + whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -330,6 +332,31 @@ 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()) + } + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a80d35b4d6..e939233749 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.75" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.9" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }