diff --git a/.cursor/rules/rules.main.mdc b/.cursor/rules/rules.main.mdc index 39f159d04b..fa5a3ccd86 100644 --- a/.cursor/rules/rules.main.mdc +++ b/.cursor/rules/rules.main.mdc @@ -19,7 +19,7 @@ alwaysApply: true - use official kotlin code guide - always use trailing commas in parameters list, except after modifiers parameter - when invoking any composable function, always pass the modifier argument last in the call, sse named parameters if needed to ensure this order, and omit trailing commas after it. -- never modify `strings.xml` +- English source copy may be updated in `app/src/main/res/values/strings.xml`; never edit localized `values-*/strings.xml` files directly - always keep compose preview at end of file - split screen composables into stateful wrapper parent and stateless child which can be rendered in the previews. - wrap previews in `AppThemeSurface` composable, and name them simple, like `Preview`, `Preview2`. diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt new file mode 100644 index 0000000000..f1ae748993 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -0,0 +1,9 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.BroadcastException +import kotlinx.coroutines.TimeoutCancellationException + +fun Throwable.isBroadcastConnectivityFailure(): Boolean = + generateSequence(this) { it.cause }.any { + it is TimeoutCancellationException || it is BroadcastException.ElectrumException + } diff --git a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt index 30136a4ff1..6b2bd5a2c7 100644 --- a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt @@ -2,6 +2,9 @@ package to.bitkit.ext import com.synonym.bitkitcore.TrezorException +/** Generic Trezor protocol Failure_FirmwareError code. */ +private const val FIRMWARE_ERROR_CODE = 99 + fun Throwable.isTrezorUserCancellation(): Boolean = generateSequence(this) { it.cause }.any { it is TrezorException.UserCancelled || @@ -11,3 +14,9 @@ fun Throwable.isTrezorUserCancellation(): Boolean = fun Throwable.isTrezorDeviceBusy(): Boolean = generateSequence(this) { it.cause }.any { it is TrezorException.DeviceBusy } + +fun Throwable.isTrezorFirmwareError(): Boolean = + generateSequence(this) { it.cause }.any { + val message = it.message.orEmpty() + "Device error (code $FIRMWARE_ERROR_CODE)" in message && "Firmware error" in message + } diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a01..78c1ea5876 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -65,6 +65,13 @@ data class HwFundingTransaction( val satsPerVByte: ULong, ) +data class HwFundingSignedTx( + val serializedTx: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) + data class HwFundingBroadcastResult( val txId: String, val miningFeeSats: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 1130dd23c8..0c08b0c63e 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -41,6 +41,7 @@ import to.bitkit.ext.timestamp import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx @@ -128,6 +129,9 @@ class HwWalletRepo @Inject constructor( /** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */ val needsPairingCode = trezorRepo.needsPairingCode + /** Identity of the active request, incremented for each pairing-code callback. */ + val pairingCodeRequestId = trezorRepo.pairingCodeRequestId + fun submitPairingCode(code: String) = trezorRepo.submitPairingCode(code) fun cancelPairingCode() = trezorRepo.cancelPairingCode() @@ -219,27 +223,23 @@ class HwWalletRepo @Inject constructor( } } - /** Signs a composed funding payment on the Trezor and broadcasts it. */ - suspend fun signAndBroadcastFunding( + /** Signs a composed funding payment on the Trezor. */ + suspend fun signFunding( deviceId: String, funding: HwFundingTransaction, - ): Result = withContext(ioDispatcher) { + ): Result = withContext(ioDispatcher) { runSuspendCatching { - val signed = runSuspendCatching { - trezorRepo.signTxFromPsbt( - psbtBase64 = funding.psbt, - network = Env.network.toTrezorCoinType(), - ).getOrThrow() - } - if (signed.isFailure) { - val failure = signed.exceptionOrNull() - if (failure?.isTrezorUserCancellation() != true) { + val signedTx = trezorRepo.signTxFromPsbt( + psbtBase64 = funding.psbt, + network = Env.network.toTrezorCoinType(), + ).getOrElse { + if (!it.isTrezorUserCancellation()) { trezorRepo.disconnectStaleSession(deviceId) } + throw it } - val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() - HwFundingBroadcastResult( - txId = txId, + HwFundingSignedTx( + serializedTx = signedTx.serializedTx, miningFeeSats = funding.miningFeeSats, feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, @@ -247,6 +247,21 @@ class HwWalletRepo @Inject constructor( } } + /** Broadcasts a signed funding payment without requiring the hardware device. */ + suspend fun broadcastFunding( + signedTx: HwFundingSignedTx, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrThrow() + HwFundingBroadcastResult( + txId = txId, + miningFeeSats = signedTx.miningFeeSats, + feeRate = signedTx.feeRate, + totalSpent = signedTx.totalSpent, + ) + } + } + suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) /** diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 56a29c92c3..8ac1839b82 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -35,10 +35,10 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable 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 @@ -52,16 +52,19 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorFirmwareError import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.HwWalletId import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath @@ -77,7 +80,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.TrezorErrorPresenter import java.io.File -import to.bitkit.models.HwWalletId import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -152,6 +154,9 @@ class TrezorRepo @Inject constructor( */ val needsPairingCode = trezorTransport.needsPairingCode + /** Identity of the active pairing-code request; changes for every transport callback. */ + val pairingCodeRequestId = trezorTransport.pairingCodeRequestId + /** * Submit the pairing code entered by the user. */ @@ -502,7 +507,7 @@ class TrezorRepo @Inject constructor( psbtBase64: String, network: TrezorCoinType?, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { ensureConnected() val response = trezorService.signTxFromPsbt(psbtBase64, network) _state.update { it.copy(error = null) } @@ -516,7 +521,7 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, @@ -716,13 +721,37 @@ class TrezorRepo @Inject constructor( } suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { - awaitConnectedOrNull(deviceId)?.let { return@withContext Result.success(it) } - if (isKnownBluetoothDevice(deviceId)) { - return@withContext reconnectKnownBluetoothDevice(deviceId) + val result = awaitConnectedOrNull(deviceId)?.let { refreshLockedFeatures(deviceId, it) } ?: run { + if (isKnownBluetoothDevice(deviceId)) { + reconnectKnownBluetoothDevice(deviceId) + } else { + connectKnownDevice(deviceId, forceSession = true) + } + } + result.requireUnlocked() + } + + private suspend fun refreshLockedFeatures( + deviceId: String, + features: TrezorFeatures, + ): Result { + if (features.pinProtection != true || features.unlocked != false) return Result.success(features) + return runSuspendCatching { trezorService.refreshFeatures() }.onSuccess { + _state.update { state -> state.copy(connected = ConnectedTrezorDevice(id = deviceId, features = it)) } } - connectKnownDevice(deviceId, forceSession = true) } + private fun Result.requireUnlocked(): Result = fold( + onSuccess = { + if (it.pinProtection == true && it.unlocked == false) { + Result.failure(TrezorException.DeviceBusy()) + } else { + Result.success(it) + } + }, + onFailure = { Result.failure(it) }, + ) + /** * 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 @@ -876,8 +905,7 @@ class TrezorRepo @Inject constructor( gapLimit = gapLimit, ) trezorService.startWatcher(params, eventBridge) - TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId' for '${extendedKey.take(12)}...'") - Logger.info("Started watcher '$watcherId'", context = TAG) + TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId'") }.onFailure { Logger.error("Start watcher failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } @@ -889,7 +917,6 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.stopWatcher(watcherId) TrezorDebugLog.log(WATCHER_TAG, "Stopped watcher '$watcherId'") - Logger.info("Stopped watcher '$watcherId'", context = TAG) }.onFailure { Logger.error("Stop watcher failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } @@ -1191,20 +1218,22 @@ class TrezorRepo @Inject constructor( } } - suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { - val connectedId = _state.value.connected?.id - if (connectedId != null && connectedId != deviceId) { - return@withContext Result.success(Unit) - } - val result = runSuspendCatching { - trezorService.disconnect() - disconnectTransportDevice(deviceId) - } - .onFailure { - Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) + suspend fun disconnectStaleSession(deviceId: String): Result = withContext(NonCancellable) { + withContext(ioDispatcher) { + val connectedId = _state.value.connected?.id + if (connectedId != null && connectedId != deviceId) { + return@withContext Result.success(Unit) + } + val result = runSuspendCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) } - _state.update { it.copy(connected = null) } - result + .onFailure { + Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) + } + _state.update { it.copy(connected = null) } + result + } } private suspend fun disconnectTransportDevice(deviceId: String) { @@ -1234,7 +1263,7 @@ class TrezorRepo @Inject constructor( } private fun trezorErrorMessage(error: Throwable): String? = - if (error.isTrezorDeviceBusy()) { + if (error.isTrezorDeviceBusy() || error.isTrezorFirmwareError()) { TrezorErrorPresenter.userMessage(context, error) } else { error.message diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt index 48ffb11154..a46f34d181 100644 --- a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -13,14 +13,20 @@ import java.util.Locale object TrezorDebugLog { private const val MAX_LINES = 300 + private const val SECRET_KEYS = "mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|" + + "extended[ _-]?key|psbt|raw[ _-]?tx|serialized[ _-]?tx" + private val quotedSecretValuePattern = Regex("""(?i)(["']?\b($SECRET_KEYS)\b["']?\s*[:=]\s*)("[^"]*"|'[^']*')""") + private val multiWordSecretValuePattern = Regex("""(?i)\b(mnemonic|seed|passphrase)\b\s*[:=]\s*[^,;}]+""") + private val secretValuePattern = Regex("""(?i)\b($SECRET_KEYS)\b\s*[:=]\s*[^\s,;}]+""") private val _lines = MutableStateFlow>(persistentListOf()) val lines: StateFlow> = _lines.asStateFlow() private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) fun log(tag: String, msg: String) { - val ts = fmt.format(Date()) - val line = "$ts [$tag] $msg" + val sanitizedMessage = sanitize(msg) + val ts = synchronized(fmt) { fmt.format(Date()) } + val line = "$ts [$tag] $sanitizedMessage" _lines.update { current -> val updated = current + line if (updated.size > MAX_LINES) { @@ -34,4 +40,14 @@ object TrezorDebugLog { fun clear() { _lines.update { persistentListOf() } } + + private fun sanitize(message: String): String { + val quotedRedacted = quotedSecretValuePattern.replace(message) { + "${it.groupValues[1]}" + } + val multiWordRedacted = multiWordSecretValuePattern.replace(quotedRedacted) { + "${it.groupValues[1]}=" + } + return secretValuePattern.replace(multiWordRedacted) { "${it.groupValues[1]}=" } + } } diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9c..5760c5ce84 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -40,6 +40,7 @@ import com.synonym.bitkitcore.trezorInitialize import com.synonym.bitkitcore.trezorIsConnected import com.synonym.bitkitcore.trezorIsInitialized import com.synonym.bitkitcore.trezorListDevices +import com.synonym.bitkitcore.trezorRefreshFeatures import com.synonym.bitkitcore.trezorScan import com.synonym.bitkitcore.trezorSetTransportCallback import com.synonym.bitkitcore.trezorSetUiCallback @@ -126,6 +127,12 @@ class TrezorService @Inject constructor( } } + suspend fun refreshFeatures(): TrezorFeatures { + return ServiceQueue.CORE.background { + trezorRefreshFeatures() + } + } + suspend fun getAddress( path: String, coin: TrezorCoinType? = TrezorCoinType.BITCOIN, diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 6a7d2672eb..e9cf39cc78 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import to.bitkit.ext.bluetoothManager +import to.bitkit.ext.nowMs import to.bitkit.ext.usbManager import to.bitkit.models.TransportType import to.bitkit.utils.Logger @@ -54,6 +55,8 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime /** * Transport callback implementation for Trezor communication. @@ -64,10 +67,12 @@ import javax.inject.Singleton * USB communication uses 64-byte chunks, Bluetooth uses 244-byte chunks. */ @Suppress("LargeClass") +@OptIn(ExperimentalTime::class) @Singleton class TrezorTransport @Inject constructor( @ApplicationContext private val context: Context, private val bridgeTransport: TrezorBridgeTransport, + private val clock: Clock, ) : TrezorTransportCallback { companion object { @@ -94,6 +99,7 @@ class TrezorTransport @Inject constructor( private const val SCAN_DURATION_MS = 3000L private const val CONNECTION_TIMEOUT_MS = 10000L private const val BLE_READ_TIMEOUT_MS = 5000L + private const val BLE_READ_POLL_INTERVAL_MS = 100L private const val DISCONNECT_TIMEOUT_MS = 3000L private const val PAIRING_CODE_TIMEOUT_MS = 120000L // 2 minutes to enter code @@ -180,7 +186,15 @@ class TrezorTransport @Inject constructor( private val connectionStateReceiver = ConnectionStateReceiver( onBluetoothOff = { bleConnections.keys.toList().forEach { path -> - bleConnections[path]?.isConnected = false + bleConnections[path]?.let { + it.isConnected = false + it.writeStatus = BluetoothGatt.GATT_FAILURE + releasePendingBleOperations( + connectionLatch = it.connectionLatch, + writeLatch = it.writeLatch, + disconnectLatch = it.disconnectLatch, + ) + } emitExternalDisconnect(path) } }, @@ -382,8 +396,6 @@ class TrezorTransport @Inject constructor( override fun getPairingCode(): String { TrezorDebugLog.log("PAIR", ">>> PAIRING CODE REQUESTED - Device requires re-pairing! <<<") - Logger.info("Requested pairing code from user", context = TAG) - Logger.info("Asked user to read the 6-digit code from Trezor screen", context = TAG) val latch = CountDownLatch(1) @@ -391,6 +403,8 @@ class TrezorTransport @Inject constructor( pairingCodeResult = null pairingCodeRequest = PairingCodeRequest(isRequested = true, latch = latch) _needsPairingCode.update { true } + val requestId = nextPairingCodeRequestId++ + _pairingCodeRequestId.update { requestId } } val result = try { @@ -459,6 +473,7 @@ class TrezorTransport @Inject constructor( pairingCodeRequest = PairingCodeRequest() pairingCodeResult = null _needsPairingCode.update { false } + _pairingCodeRequestId.update { null } } /** @@ -468,6 +483,10 @@ class TrezorTransport @Inject constructor( private val _needsPairingCode = MutableStateFlow(false) val needsPairingCode: StateFlow = _needsPairingCode + private var nextPairingCodeRequestId = 1L + private val _pairingCodeRequestId = MutableStateFlow(null) + val pairingCodeRequestId: StateFlow = _pairingCodeRequestId + /** * Submit a pairing code from the UI. * This unblocks the getPairingCode() call waiting on the Rust side. @@ -674,13 +693,17 @@ class TrezorTransport @Inject constructor( return UsbEndpoints(read = readEndpoint, write = writeEndpoint) } - @Suppress("TooGenericExceptionCaught", "ReturnCount") + @Suppress("TooGenericExceptionCaught", "LongMethod", "ReturnCount") private fun openUsbDevice(path: String): TrezorTransportWriteResult { return try { closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Device not found: $path", + errorCode = null, + ) if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { @@ -701,12 +724,20 @@ class TrezorTransport @Inject constructor( } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Failed to open device: $path", + errorCode = null, + ) val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to claim interface", + errorCode = null, + ) } val endpoints = findUsbEndpoints(usbInterface) @@ -785,7 +816,12 @@ class TrezorTransport @Inject constructor( TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Unknown error", + errorCode = null, + ) } } @@ -793,7 +829,11 @@ class TrezorTransport @Inject constructor( private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Device not open: $path", + errorCode = null, + ) val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -875,14 +915,22 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to initiate bonding", + errorCode = null, + ) } var bondAttempts = 0 while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Bonding failed or rejected", + errorCode = null, + ) } } if (device.bondState != BluetoothDevice.BOND_BONDED) { @@ -976,8 +1024,11 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) connection.readQueue.clear() - connection.writeLatch?.countDown() - connection.connectionLatch?.countDown() + releasePendingBleOperations( + connectionLatch = connection.connectionLatch, + writeLatch = connection.writeLatch, + disconnectLatch = connection.disconnectLatch, + ) Logger.info("Closed BLE device session '$path'", context = TAG) return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @@ -991,7 +1042,7 @@ class TrezorTransport @Inject constructor( userInitiatedCloseSet.add(path) return try { val disconnectLatch = CountDownLatch(1) - bleConnections[path] = connection.copy(disconnectLatch = disconnectLatch) + connection.disconnectLatch = disconnectLatch connection.gatt.disconnect() @@ -1025,19 +1076,39 @@ class TrezorTransport @Inject constructor( ) return try { - val data = connection.readQueue.poll(BLE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) - ?: return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Read timeout", - errorCode = null, - ) - - Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "", errorCode = null) + val deadlineMs = clock.nowMs() + BLE_READ_TIMEOUT_MS + while (clock.nowMs() < deadlineMs) { + if (!connection.isConnected) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "BLE disconnected", + errorCode = null, + ) + } + val remainingMs = deadlineMs - clock.nowMs() + if (remainingMs <= 0) break + val pollMs = minOf(BLE_READ_POLL_INTERVAL_MS, remainingMs) + val data = connection.readQueue.poll(pollMs, TimeUnit.MILLISECONDS) + if (data != null) { + Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) + return TrezorTransportReadResult(success = true, data = data, error = "", errorCode = null) + } + } + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Read timeout", + errorCode = null, + ) } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Read failed", + errorCode = null, + ) } } @@ -1055,7 +1126,11 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available", errorCode = null) + ?: return TrezorTransportWriteResult( + success = false, + error = "Write characteristic not available", + errorCode = null, + ) if (!connection.isConnected) { Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) @@ -1361,3 +1436,13 @@ class TrezorTransport @Inject constructor( bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } + +internal fun releasePendingBleOperations( + connectionLatch: CountDownLatch?, + writeLatch: CountDownLatch?, + disconnectLatch: CountDownLatch?, +) { + connectionLatch?.countDown() + writeLatch?.countDown() + disconnectLatch?.countDown() +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2b1642a37a..b41ddca8e3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -216,6 +216,7 @@ import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.SettingsViewModel +import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -646,6 +647,13 @@ private fun RootNavHost( onHomeCalculatorInputActiveChanged: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + transferViewModel.transferEffects.collect { effect -> + if (effect is TransferEffect.OnHwTxSigned) { + navController.navigateTo(Routes.SpendingHwSigned) + } + } + } NavHost(navController, startDestination = Routes.Home) { home( @@ -804,7 +812,6 @@ private fun RootNavHost( onCloseClick = { navController.navigateToHome() }, onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, - onSigned = { navController.navigateTo(Routes.SpendingHwSigned) }, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index ad1f623ccb..d91a05306b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -93,7 +93,6 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() - TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) @@ -107,6 +106,8 @@ fun SpendingAdvancedScreen( description = effect.description, ) } + + else -> Unit } } } 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 25544379df..a47fbc9a91 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -35,7 +36,6 @@ import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferViewModel @Composable @@ -46,7 +46,6 @@ fun SpendingHwSignScreen( onCloseClick: () -> Unit, onLearnMoreClick: () -> Unit, onAdvancedClick: () -> Unit, - onSigned: () -> Unit, ) { val state by viewModel.spendingUiState.collectAsStateWithLifecycle() @@ -59,19 +58,15 @@ fun SpendingHwSignScreen( viewModel.warmUpHardwareConnection(deviceId) } - LaunchedEffect(Unit) { - viewModel.transferEffects.collect { effect -> - when (effect) { - TransferEffect.OnHwTxSigned -> onSigned() - else -> Unit - } - } + DisposableEffect(viewModel) { + onDispose(viewModel::cancelHardwareTransfer) } Content( order = order, isAdvanced = state.isAdvanced, isSigning = state.isSigning, + hasPendingBroadcast = state.hasPendingHwBroadcast, onBackClick = onBackClick, onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, @@ -85,6 +80,7 @@ private fun Content( order: IBtOrder, isAdvanced: Boolean = false, isSigning: Boolean = false, + hasPendingBroadcast: Boolean = false, onBackClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, @@ -111,7 +107,13 @@ private fun Content( ) { VerticalSpacer(32.dp) Display( - text = stringResource(R.string.lightning__transfer_hw__sign_title) + text = stringResource( + if (hasPendingBroadcast) { + R.string.lightning__transfer_hw__signed_title + } else { + R.string.lightning__transfer_hw__sign_title + } + ) .withAccent(accentColor = Colors.Purple), modifier = Modifier.fillMaxWidth() ) @@ -126,6 +128,7 @@ private fun Content( text = stringResource(R.string.common__learn_more), size = ButtonSize.Small, fullWidth = false, + enabled = !isSigning && !hasPendingBroadcast, onClick = onLearnMoreClick, modifier = Modifier.testTag("HardwareTransferSignLearnMore") ) @@ -135,6 +138,7 @@ private fun Content( ), size = ButtonSize.Small, fullWidth = false, + enabled = !isSigning && !hasPendingBroadcast, onClick = { if (isAdvanced) onUseDefaultLspBalanceClick() else onAdvancedClick() }, modifier = Modifier.testTag( if (isAdvanced) "HardwareTransferSignDefault" else "HardwareTransferSignAdvanced" @@ -145,7 +149,13 @@ private fun Content( FillHeight() PrimaryButton( - text = stringResource(R.string.lightning__transfer_hw__open_connect), + text = stringResource( + if (hasPendingBroadcast) { + R.string.common__retry + } else { + R.string.lightning__transfer_hw__open_connect + } + ), onClick = onOpenConnect, enabled = !isSigning, isLoading = isSigning, @@ -223,3 +233,14 @@ private fun PreviewSigning() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewPendingBroadcast() { + AppThemeSurface { + Content( + order = previewBtOrder(), + hasPendingBroadcast = true, + ) + } +} 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 6c9f0f831e..624c316a75 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 @@ -498,7 +498,6 @@ class TrezorViewModel @Inject constructor( val feeRate = state.sendFeeRate.toFloatOrNull() ?: return@launch _uiState.update { it.copy(send = it.send.copy(isComposing = true)) } TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") - TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") TrezorDebugLog.log("COMPOSE", "feeRate=$feeRate sat/vB, network=${state.selectedNetwork}") TrezorDebugLog.log("COMPOSE", "coinSelection=${state.coinSelection}") diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index 195194b578..11e708c96d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -373,7 +373,11 @@ private fun GeneralTabContent( SettingsButtonRow( title = stringResource(R.string.settings__hardware_wallets__nav_title), icon = { SettingsIcon(R.drawable.ic_device_mobile_speaker) }, - value = SettingsButtonValue.StringValue(state.hardwareWalletCount.toString()), + value = if (state.hardwareWalletCount > 0) { + SettingsButtonValue.StringValue(state.hardwareWalletCount.toString()) + } else { + SettingsButtonValue.None + }, onClick = { onEvent(SettingsEvent.HardwareWalletsClick) }, modifier = Modifier.testTag("HardwareWalletsSettings") ) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt index 633becf2ab..610c12b34f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt @@ -124,6 +124,12 @@ fun HardwareSheet( onFinish = onFinish, ) + LaunchedEffect(sheet.route) { + if (sheet.route is HardwareRoute.PairCode) { + navController.navigateTo(sheet.route) + } + } + Column( modifier = Modifier .fillMaxWidth() @@ -175,8 +181,10 @@ fun HardwareSheet( onFinish = viewModel::onFinishClick, ) } - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() HwPairCodeSheet( + requestId = route.requestId, onSubmit = appViewModel::submitPairingCode, onCancel = appViewModel::cancelPairingCode, ) @@ -220,7 +228,9 @@ private fun ConnectEffectHandler( deviceModel = effect.deviceModel, ), ) - HwConnectEffect.NavigateToPairCode -> navController.navigateTo(HardwareRoute.PairCode) + is HwConnectEffect.NavigateToPairCode -> navController.navigateTo( + HardwareRoute.PairCode(requestId = effect.requestId), + ) HwConnectEffect.NavigateToPaired -> navController.navigateTo(HardwareRoute.Paired) HwConnectEffect.Dismiss -> appViewModel.hideSheet() HwConnectEffect.Finish -> { @@ -249,5 +259,5 @@ sealed interface HardwareRoute { data object Paired : HardwareRoute @Serializable - data object PairCode : HardwareRoute + data class PairCode(val requestId: Long) : HardwareRoute } diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 426a487bda..392abc1a29 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -17,9 +17,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH -import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.repositories.resolveHwWalletName import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject @@ -230,8 +230,8 @@ class HwConnectViewModel @Inject constructor( private fun observePairingCode() { viewModelScope.launch { - hwWalletRepo.needsPairingCode.collect { needsCode -> - if (needsCode) setEffect(HwConnectEffect.NavigateToPairCode) + hwWalletRepo.pairingCodeRequestId.collect { requestId -> + if (requestId != null) setEffect(HwConnectEffect.NavigateToPairCode(requestId)) } } } @@ -272,7 +272,7 @@ data class HwConnectUiState( sealed interface HwConnectEffect { data object NavigateToSearching : HwConnectEffect data class NavigateToFound(val deviceId: String, val deviceModel: String) : HwConnectEffect - data object NavigateToPairCode : HwConnectEffect + data class NavigateToPairCode(val requestId: Long) : HwConnectEffect data object NavigateToPaired : HwConnectEffect data object Dismiss : HwConnectEffect data object Finish : HwConnectEffect diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt index 1df72ef573..105a429cf2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt @@ -50,16 +50,17 @@ private val PAIRING_CELL_SPACING = 8.dp @Composable fun HwPairCodeSheet( + requestId: Long, onSubmit: (String) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { - var code by remember { mutableStateOf("") } - var submitted by remember { mutableStateOf(false) } + var code by remember(requestId) { mutableStateOf("") } + var submitted by remember(requestId) { mutableStateOf(false) } // Dismissing the sheet without submitting (e.g. swipe down) cancels the pending // pairing request so the connect attempt does not hang until its timeout. - DisposableEffect(Unit) { + DisposableEffect(requestId) { onDispose { if (!submitted) onCancel() } } diff --git a/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt b/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt index 85bcfafee2..4f9ea19d1c 100644 --- a/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt +++ b/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt @@ -3,12 +3,14 @@ package to.bitkit.utils import android.content.Context import to.bitkit.R import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorFirmwareError object TrezorErrorPresenter { fun userMessage(context: Context, error: Throwable): String { if (error.isTrezorDeviceBusy()) { return context.getString(R.string.hardware__device_busy) } + if (error.isTrezorFirmwareError()) return context.getString(R.string.hardware__connect_error) return userMessage( context = context, error = error, @@ -20,6 +22,7 @@ object TrezorErrorPresenter { if (error.isTrezorDeviceBusy()) { return context.getString(R.string.hardware__device_busy) } + if (error.isTrezorFirmwareError()) return context.getString(R.string.hardware__connect_error) return error.message?.takeIf { it.isNotBlank() } ?: fallback } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dd7217c838..d9c7c91c3c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -259,7 +259,7 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() - private var isPairingCodeSheetQueued = false + private var queuedPairingCodeRequestId: Long? = null private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() @@ -341,13 +341,13 @@ class AppViewModel @Inject constructor( } } viewModelScope.launch { - hwWalletRepo.needsPairingCode.collect { needsCode -> - if (needsCode) { - showPairingCodeSheet() + hwWalletRepo.pairingCodeRequestId.collect { requestId -> + if (requestId != null) { + showPairingCodeSheet(requestId) } else { - isPairingCodeSheetQueued = false + queuedPairingCodeRequestId = null _currentSheet.update { sheet -> - if (sheet is Sheet.Hardware && sheet.route == HardwareRoute.PairCode) null else sheet + if (sheet is Sheet.Hardware && sheet.route is HardwareRoute.PairCode) null else sheet } } } @@ -3103,28 +3103,35 @@ class AppViewModel @Inject constructor( * any screen via silent reconnects, so the sheet is shown app-wide. High-priority * sheets are not interrupted: the pairing sheet waits until the active sheet closes. */ - private fun showPairingCodeSheet() { + private fun showPairingCodeSheet(requestId: Long) { if (isHighPrioritySheet(_currentSheet.value)) { - isPairingCodeSheetQueued = true + queuedPairingCodeRequestId = requestId return } - // The Connect Hardware flow is itself a Hardware sheet and drives the pair-code step - // inline within its own NavHost; replacing it here would tear down that back stack. - if (_currentSheet.value is Sheet.Hardware) return + // The Connect Hardware flow drives pair-code requests through its own NavHost. An app-wide + // PairCode sheet has no connect back stack to preserve, so replace its start route when the + // device requests a new code and force the input state to reset. + val currentSheet = _currentSheet.value + if (currentSheet is Sheet.Hardware) { + if (currentSheet.route is HardwareRoute.PairCode && currentSheet.route.requestId != requestId) { + _currentSheet.update { Sheet.Hardware(route = HardwareRoute.PairCode(requestId)) } + } + return + } - isPairingCodeSheetQueued = false - showSheet(Sheet.Hardware(route = HardwareRoute.PairCode)) + queuedPairingCodeRequestId = null + showSheet(Sheet.Hardware(route = HardwareRoute.PairCode(requestId))) } private fun showQueuedPairingCodeSheet() { - if (!isPairingCodeSheetQueued) return + val requestId = queuedPairingCodeRequestId ?: return if (!hwWalletRepo.needsPairingCode.value) { - isPairingCodeSheetQueued = false + queuedPairingCodeRequestId = null return } - showPairingCodeSheet() + showPairingCodeSheet(requestId) } private fun isHighPrioritySheet(sheet: Sheet?) = sheet is Sheet.Gift || diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 05aa1c171e..a13c3d5810 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -33,9 +33,13 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose +import to.bitkit.ext.isBroadcastConnectivityFailure import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorFirmwareError import to.bitkit.ext.isTrezorUserCancellation +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -49,7 +53,6 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject import kotlin.math.min import kotlin.math.roundToLong @@ -97,6 +100,8 @@ class TransferViewModel @Inject constructor( fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } var maxLspFee = 0uL private var hwTransferSignJob: Job? = null + private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null + private var activeHwTransferDeviceId: String? = null // region Spending @@ -387,7 +392,15 @@ class TransferViewModel @Inject constructor( private suspend fun onOrderCreated(order: IBtOrder) { settingsStore.update { it.copy(lightningSetupStep = 0) } - _spendingUiState.update { it.copy(order = order, isAdvanced = false, defaultOrder = null) } + pendingHwFundingBroadcast = null + _spendingUiState.update { + it.copy( + order = order, + isAdvanced = false, + defaultOrder = null, + hasPendingHwBroadcast = false, + ) + } setTransferEffect(TransferEffect.OnOrderCreated) } @@ -484,16 +497,37 @@ class TransferViewModel @Inject constructor( fun onUseDefaultLspBalanceClick() { val defaultOrder = _spendingUiState.value.defaultOrder - _spendingUiState.update { it.copy(order = defaultOrder, defaultOrder = null, isAdvanced = false) } + _spendingUiState.update { + it.copy( + order = defaultOrder, + defaultOrder = null, + isAdvanced = false, + ) + } } fun resetSpendingState() { hwTransferSignJob?.cancel() hwTransferSignJob = null + pendingHwFundingBroadcast = null + activeHwTransferDeviceId = null _spendingUiState.update { TransferToSpendingUiState() } _transferValues.update { TransferValues() } } + fun cancelHardwareTransfer() { + if (pendingHwFundingBroadcast != null) return + val deviceId = activeHwTransferDeviceId + hwTransferSignJob?.cancel() + hwTransferSignJob = null + _spendingUiState.update { it.copy(isSigning = false) } + if (deviceId != null) { + viewModelScope.launch { + hwWalletRepo.disconnectStaleSession(deviceId) + } + } + } + // endregion // region Hardware Wallet @@ -533,6 +567,7 @@ class TransferViewModel @Inject constructor( fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { if (hwTransferSignJob?.isActive == true) return + activeHwTransferDeviceId = deviceId hwTransferSignJob = viewModelScope.launch { _spendingUiState.update { it.copy(isSigning = true) } try { @@ -542,16 +577,25 @@ class TransferViewModel @Inject constructor( return@launch } - signTransferToSpendingWithHardware(order, deviceId, address) + signAndBroadcastHardwareFunding(order, deviceId, address) .onSuccess { result -> - fundPaidOrder( - order = order, - txId = result.txId, - createTransferActivity = true, - fee = result.miningFeeSats, - feeRate = result.feeRate, - ) - setTransferEffect(TransferEffect.OnHwTxSigned) + runSuspendCatching { + fundPaidOrder( + order = order, + txId = result.txId, + createTransferActivity = true, + fee = result.miningFeeSats, + feeRate = result.feeRate, + ) + }.onSuccess { + pendingHwFundingBroadcast = null + activeHwTransferDeviceId = null + _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } + setTransferEffect(TransferEffect.OnHwTxSigned) + }.onFailure { + Logger.error("Failed to record broadcast hardware transfer", it, context = TAG) + handleHardwareTransferFailure(it, deviceId) + } } .onFailure { handleHardwareTransferFailure(it, deviceId) } } finally { @@ -561,21 +605,26 @@ class TransferViewModel @Inject constructor( } } - private suspend fun signTransferToSpendingWithHardware( + private suspend fun signAndBroadcastHardwareFunding( order: IBtOrder, deviceId: String, address: String, ): Result { val result = runCatching { - ensureHardwareConnected(deviceId) - val satsPerVByte = hwFundingSatsPerVByte() - val funding = composeHardwareFundingTransaction( - deviceId = deviceId, - address = address, - sats = order.feeSat, - satsPerVByte = satsPerVByte, - ) - signAndBroadcastHardwareFunding(deviceId, funding) + val signedTx = pendingHwFundingBroadcast + ?.takeIf { it.matches(order, deviceId, address) } + ?.signedTx + ?: prepareSignedHardwareFunding(order, deviceId, address).also { + pendingHwFundingBroadcast = PendingHwFundingBroadcast( + orderId = order.id, + deviceId = deviceId, + address = address, + amountSats = order.feeSat, + signedTx = it, + ) + _spendingUiState.update { state -> state.copy(hasPendingHwBroadcast = true) } + } + broadcastHardwareFunding(signedTx) } result.exceptionOrNull()?.let { if (it is CancellationException && it !is TimeoutCancellationException) throw it @@ -583,6 +632,23 @@ class TransferViewModel @Inject constructor( return result } + private suspend fun prepareSignedHardwareFunding( + order: IBtOrder, + deviceId: String, + address: String, + ): HwFundingSignedTx { + ensureHardwareConnected(deviceId) + val satsPerVByte = hwFundingSatsPerVByte() + val funding = composeHardwareFundingTransaction( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = satsPerVByte, + ) + return signHardwareFunding(deviceId, funding) + } + + @Suppress("ThrowsCount") private suspend fun ensureHardwareConnected(deviceId: String) { runCatching { withTimeout(HW_RECONNECT_TIMEOUT) { @@ -600,30 +666,28 @@ class TransferViewModel @Inject constructor( address: String, sats: ULong, satsPerVByte: ULong, - ): HwFundingTransaction { - return runCatching { - withTimeout(HW_COMPOSE_TIMEOUT) { - hwWalletRepo.composeFundingTransaction( - deviceId = deviceId, - address = address, - sats = sats, - satsPerVByte = satsPerVByte, - ).getOrThrow() - } - }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it - throw HardwareFundingError(it) + ): HwFundingTransaction = runCatching { + withTimeout(HW_COMPOSE_TIMEOUT) { + hwWalletRepo.composeFundingTransaction( + deviceId = deviceId, + address = address, + sats = sats, + satsPerVByte = satsPerVByte, + ).getOrThrow() } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareFundingError(it) } @Suppress("ThrowsCount") - private suspend fun signAndBroadcastHardwareFunding( + private suspend fun signHardwareFunding( deviceId: String, funding: HwFundingTransaction, - ): HwFundingBroadcastResult { + ): HwFundingSignedTx { return runCatching { withTimeout(HW_SIGN_TIMEOUT) { - hwWalletRepo.signAndBroadcastFunding( + hwWalletRepo.signFunding( deviceId = deviceId, funding = funding, ).getOrThrow() @@ -638,21 +702,42 @@ class TransferViewModel @Inject constructor( } } + private suspend fun broadcastHardwareFunding( + signedTx: HwFundingSignedTx, + ): HwFundingBroadcastResult { + return runCatching { + withTimeout(HW_BROADCAST_TIMEOUT) { + hwWalletRepo.broadcastFunding(signedTx).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareBroadcastError(it) + } + } + private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { if (e.isTrezorUserCancellation()) { Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) return } if (e.isTrezorDeviceBusy()) { - Logger.warn("Hardware transfer blocked by busy Trezor for '$deviceId'", e, context = TAG) + Logger.warn("Blocked hardware transfer for locked or busy Trezor '$deviceId'", e, context = TAG) ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = TrezorErrorPresenter.userMessage(context, e), + type = Toast.ToastType.INFO, + title = context.getString(R.string.hardware__device_busy), ) return } + if (e.isTrezorFirmwareError()) { + Logger.warn("Received Trezor firmware error for '$deviceId'", e, context = TAG) + showHardwareReconnectRequiredError() + return + } when (e) { + is HardwareBroadcastError -> { + Logger.warn("Hardware funding transaction is signed but not confirmed broadcast", e, context = TAG) + showHardwareBroadcastError(e) + } is HardwareReconnectError -> { Logger.error("Failed to reconnect hardware device", e, context = TAG) showHardwareReconnectError(deviceId) @@ -663,7 +748,11 @@ class TransferViewModel @Inject constructor( } is HardwareFundingError -> { Logger.warn("Failed to compose hardware transfer funding for '$deviceId'", e, context = TAG) - showHardwareFundingError(e) + if (e.isHardwareInteractionTimeout()) { + showHardwareConnectivityError() + } else { + showHardwareFundingError(e) + } } else -> { Logger.error("Hardware transfer failed", e, context = TAG) @@ -672,14 +761,41 @@ class TransferViewModel @Inject constructor( } } + private suspend fun showHardwareBroadcastError(error: HardwareBroadcastError) { + if (!error.isBroadcastConnectivityFailure()) { + pendingHwFundingBroadcast = null + _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } + ToastEventBus.send(error.cause ?: error) + return + } + showHardwareConnectivityError() + } + + private suspend fun showHardwareConnectivityError() { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__connection_issue), + description = context.getString(R.string.other__connection_issues_explain), + ) + } + + private fun Throwable.isHardwareInteractionTimeout(): Boolean = + this is HardwareFundingError && + generateSequence(this) { it.cause }.any { it is TimeoutCancellationException } + private suspend fun showHardwareReconnectError(deviceId: String) { if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { ToastEventBus.send( type = Toast.ToastType.INFO, - title = context.getString(R.string.hardware__connect_error), + title = context.getString(R.string.hardware__connect_title), + description = context.getString(R.string.hardware__connect_error), ) return } + showHardwareReconnectRequiredError() + } + + private suspend fun showHardwareReconnectRequiredError() { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), @@ -944,6 +1060,9 @@ class TransferViewModel @Inject constructor( /** Upper bound for one hardware signing attempt before the UI releases the button. */ private val HW_SIGN_TIMEOUT = 120.seconds + + /** Upper bound for broadcasting a signed hardware funding transaction. */ + private val HW_BROADCAST_TIMEOUT = 120.seconds const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -954,6 +1073,21 @@ class TransferViewModel @Inject constructor( private class HardwareReconnectError(cause: Throwable) : AppError(cause) private class HardwareFundingError(cause: Throwable) : AppError(cause) private class HardwareSigningTimeoutError(cause: Throwable) : AppError(cause) +private class HardwareBroadcastError(cause: Throwable) : AppError(cause) + +private data class PendingHwFundingBroadcast( + val orderId: String, + val deviceId: String, + val address: String, + val amountSats: ULong, + val signedTx: HwFundingSignedTx, +) { + fun matches(order: IBtOrder, deviceId: String, address: String): Boolean = + orderId == order.id && + this.deviceId == deviceId && + this.address == address && + amountSats == order.feeSat +} // region state data class TransferToSpendingUiState( @@ -965,6 +1099,7 @@ data class TransferToSpendingUiState( val quarterAmount: Long = 0, val isLoading: Boolean = false, val isSigning: Boolean = false, + val hasPendingHwBroadcast: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4f18f5169..60b5feb970 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,7 +167,7 @@ Open Settings Bitkit needs the nearby-devices (Bluetooth) permission to find your hardware wallet. Enable it in Settings, then try again. Bluetooth access needed - Could not connect to your Trezor. Check that it is unlocked and try again. + Could not connect to your hardware device. Make sure it is unlocked, nearby, and not connected to another phone or computer, then try again. Searching for <accent>devices</accent> Please connect your hardware wallet now via USB or Bluetooth. Connect Device diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt new file mode 100644 index 0000000000..f4e5c99846 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -0,0 +1,33 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.BroadcastException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Test +import to.bitkit.utils.AppError +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BroadcastExceptionExtTest { + @Test + fun `electrum broadcast failures are connectivity failures`() { + val error = AppError(BroadcastException.ElectrumException("DNS lookup failed")) + + assertTrue(error.isBroadcastConnectivityFailure()) + } + + @Test + fun `broadcast timeout failures are connectivity failures`() = runTest { + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + + assertTrue(timeout.isBroadcastConnectivityFailure()) + } + + @Test + fun `unrelated broadcast failures are not connectivity failures`() { + val error = AppError(BroadcastException.InvalidTransaction("bad tx")) + + assertFalse(error.isBroadcastConnectivityFailure()) + } +} diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index 1900be86da..0fbac4f234 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -45,4 +45,17 @@ class TrezorExceptionExtTest { assertFalse(TrezorException.UserCancelled().isTrezorDeviceBusy()) assertFalse(AppError("sign failed").isTrezorDeviceBusy()) } + + @Test + fun `isTrezorFirmwareError recognizes protocol firmware error response`() { + val error = AppError("Device error (code 99): Firmware error") + + assertTrue(error.isTrezorFirmwareError()) + } + + @Test + fun `isTrezorFirmwareError rejects unrelated firmware errors`() { + assertFalse(AppError("Firmware error").isTrezorFirmwareError()) + assertFalse(AppError("Device error (code 98): Firmware error").isTrezorFirmwareError()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 29294a0e3b..73e23f56ee 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -5,8 +5,8 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorException +import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -33,13 +33,14 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -500,7 +501,10 @@ class HwWalletRepoTest : BaseUnitTest() { advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify( + trezorRepo, + times(2) + ).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -649,7 +653,10 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify( + trezorRepo, + never() + ).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) watcherEvents.emit( "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -945,11 +952,11 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signAndBroadcastFunding returns txid and composed fee data`() = test { + fun `signFunding returns signed transaction and composed fee data`() = test { val signedTx = TrezorSignedTx( signatures = emptyList(), serializedTx = "rawtx", - txid = "signed-txid", + txid = null, ) val funding = HwFundingTransaction( psbt = "psbt", @@ -960,20 +967,58 @@ class HwWalletRepoTest : BaseUnitTest() { ) whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) .thenReturn(Result.success(signedTx)) + val sut = createRepo() + + val result = sut.signFunding("dev1", funding) + + assertEquals(true, result.isSuccess) + assertEquals("rawtx", result.getOrThrow().serializedTx) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(3uL, result.getOrThrow().feeRate) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + + @Test + fun `broadcastFunding returns txid and signed fee data`() = test { + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = 1_250uL, + feeRate = 3uL, + totalSpent = 26_250uL, + ) whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.broadcastFunding(signedTx) assertEquals(true, result.isSuccess) assertEquals("broadcast-txid", result.getOrThrow().txId) assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) + verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) } @Test - fun `signAndBroadcastFunding disconnects stale session when sign fails`() = test { + fun `broadcastFunding returns core-derived txid when transaction is already known`() = test { + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = 1_250uL, + feeRate = 3uL, + totalSpent = 26_250uL, + ) + whenever(trezorRepo.broadcastRawTx("rawtx")) + .thenReturn(Result.success("core-derived-txid")) + val sut = createRepo() + + val result = sut.broadcastFunding(signedTx) + + assertEquals("core-derived-txid", result.getOrThrow().txId) + } + + @Test + fun `signFunding disconnects stale session when sign fails`() = test { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, @@ -986,7 +1031,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.signFunding("dev1", funding) assertEquals(true, result.isFailure) verify(trezorRepo).disconnectStaleSession("dev1") @@ -994,7 +1039,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signAndBroadcastFunding keeps session when user cancels on device`() = test { + fun `signFunding keeps session when user cancels on device`() = test { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, @@ -1006,7 +1051,7 @@ class HwWalletRepoTest : BaseUnitTest() { .thenReturn(Result.failure(TrezorException.UserCancelled())) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.signFunding("dev1", funding) assertEquals(true, result.isFailure) verify(trezorRepo, never()).disconnectStaleSession(any()) @@ -1075,7 +1120,6 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5b135450a4..8ce451bae0 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -144,9 +144,13 @@ class TrezorRepoTest : BaseUnitTest() { private fun mockFeatures( label: String? = DEVICE_LABEL, model: String? = DEVICE_MODEL, + pinProtection: Boolean? = null, + unlocked: Boolean? = null, ): TrezorFeatures = mock { on { this.label }.thenReturn(label) on { this.model }.thenReturn(model) + on { this.pinProtection }.thenReturn(pinProtection) + on { this.unlocked }.thenReturn(unlocked) } private fun mockPublicKeyResponse( @@ -455,6 +459,33 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).scan() } + @Test + fun `bluetooth restore reconnects only after pairing request clears`() = test { + val transportRestored = MutableSharedFlow() + val needsPairingCode = MutableStateFlow(true) + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorTransport.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features) + sut = createSut() + + transportRestored.emit(TransportType.BLUETOOTH) + advanceUntilIdle() + verify(trezorService, never()).scan() + + needsPairingCode.value = false + transportRestored.emit(TransportType.BLUETOOTH) + advanceUntilIdle() + + verify(trezorService).scan() + verify(trezorService).connect(eq(DEVICE_ID), any(), eq(false)) + } + @Test fun `onTransportRestored auto-reconnects to a known device`() = test { val features = mockFeatures() @@ -1491,6 +1522,48 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).disconnect() } + @Test + fun `ensureConnected returns device busy when current device is locked`() = test { + val features = mockFeatures(pinProtection = true, unlocked = false) + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.refreshFeatures()).thenReturn(features) + + val result = sut.ensureConnected(DEVICE_ID) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.isTrezorDeviceBusy() == true) + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + + @Test + fun `ensureConnected refreshes cached locked features after device unlock`() = test { + val lockedFeatures = mockFeatures(pinProtection = true, unlocked = false) + val unlockedFeatures = mockFeatures(pinProtection = true, unlocked = true) + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(lockedFeatures) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.refreshFeatures()).thenReturn(unlockedFeatures) + + val result = sut.ensureConnected(DEVICE_ID) + + assertEquals(unlockedFeatures, result.getOrNull()) + assertEquals(unlockedFeatures, sut.state.value.connected?.features) + verify(trezorService).refreshFeatures() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + @Test fun `ensureConnected retries bluetooth reconnect until scan finds the device`() = test { val bleDeviceId = "ble:57:21:A7:F9:DD:AD" diff --git a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt new file mode 100644 index 0000000000..7061c68327 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt @@ -0,0 +1,57 @@ +package to.bitkit.services + +import org.junit.After +import org.junit.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class TrezorDebugLogTest { + @After + fun tearDown() { + TrezorDebugLog.clear() + } + + @Test + fun `sensitive diagnostic values are redacted`() { + TrezorDebugLog.log("REDACTION_TEST", "pin=1234 passphrase='hidden wallet'") + + val line = TrezorDebugLog.lines.value.last { "[REDACTION_TEST]" in it } + + assertContains(line, "pin=") + assertContains(line, "passphrase=") + assertFalse("1234" in line) + assertFalse("hidden wallet" in line) + } + + @Test + fun `structured sensitive diagnostic values are redacted`() { + TrezorDebugLog.log( + "STRUCTURED_REDACTION_TEST", + """{"xpub":"xpub-secret","psbt":"psbt-secret","raw_tx":"raw-secret","pin":"1234","passphrase":"hidden"}""", + ) + + val line = TrezorDebugLog.lines.value.last { "[STRUCTURED_REDACTION_TEST]" in it } + + assertFalse("xpub-secret" in line) + assertFalse("psbt-secret" in line) + assertFalse("raw-secret" in line) + assertFalse("1234" in line) + assertFalse("hidden" in line) + } + + @Test + fun `unquoted multi word sensitive diagnostic values are fully redacted`() { + TrezorDebugLog.log( + "MULTI_WORD_REDACTION_TEST", + "mnemonic=abandon ability able passphrase=my hidden wallet", + ) + + val line = TrezorDebugLog.lines.value.last { "[MULTI_WORD_REDACTION_TEST]" in it } + + assertContains(line, "mnemonic=") + assertFalse("abandon" in line) + assertFalse("ability" in line) + assertFalse("able" in line) + assertFalse("my hidden wallet" in line) + } +} diff --git a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt index 4b79152802..9d5f8b411b 100644 --- a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt @@ -12,10 +12,18 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.Collections +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class TrezorTransportTest { private val context = mock() @@ -64,8 +72,58 @@ class TrezorTransportTest { verify(context, never()).getSystemService(Context.BLUETOOTH_SERVICE) } + @Test + fun `each pairing callback emits a distinct request id`() { + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("to.bitkit.dev") + val sut = createSut() + val results = Collections.synchronizedList(mutableListOf()) + + val firstCallback = thread { results += sut.getPairingCode() } + val firstRequestId = awaitPairingRequestId(sut) + sut.submitPairingCode("123456") + firstCallback.join() + + val secondCallback = thread { results += sut.getPairingCode() } + val secondRequestId = awaitPairingRequestId(sut, excluding = firstRequestId) + sut.submitPairingCode("654321") + secondCallback.join() + + assertNotEquals(firstRequestId, secondRequestId) + assertEquals(listOf("123456", "654321"), results) + } + + @Test + fun `bluetooth off releases every pending operation`() { + val connectionLatch = CountDownLatch(1) + val writeLatch = CountDownLatch(1) + val disconnectLatch = CountDownLatch(1) + + releasePendingBleOperations( + connectionLatch = connectionLatch, + writeLatch = writeLatch, + disconnectLatch = disconnectLatch, + ) + + assertEquals(0L, connectionLatch.count) + assertEquals(0L, writeLatch.count) + assertEquals(0L, disconnectLatch.count) + } + + private fun awaitPairingRequestId( + sut: TrezorTransport, + excluding: Long? = null, + ): Long { + repeat(100) { + sut.pairingCodeRequestId.value?.takeIf { it != excluding }?.let { return it } + Thread.sleep(10) + } + fail("Pairing code request was not emitted") + } + private fun createSut() = TrezorTransport( context = context, bridgeTransport = bridgeTransport, + clock = Clock.System, ) } diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index b4bfbca67c..8c6c0f280b 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -34,7 +34,7 @@ class HwConnectViewModelTest : BaseUnitTest() { private val hwWalletRepo = mock() private val context = mock() - private val needsPairingCode = MutableStateFlow(false) + private val pairingCodeRequestId = MutableStateFlow(null) private val wallets = MutableStateFlow>(persistentListOf()) private val deviceState = MutableStateFlow(TrezorState()) @@ -42,7 +42,7 @@ class HwConnectViewModelTest : BaseUnitTest() { @Before fun setUp() { - whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(hwWalletRepo.wallets).thenReturn(wallets) whenever(hwWalletRepo.deviceState).thenReturn(deviceState) whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_ERROR) @@ -225,8 +225,19 @@ class HwConnectViewModelTest : BaseUnitTest() { @Test fun `pairing code request surfaces the inline pair code step`() = test { sut.effects.test { - needsPairingCode.value = true - assertEquals(HwConnectEffect.NavigateToPairCode, awaitItem()) + pairingCodeRequestId.value = 1L + assertEquals(HwConnectEffect.NavigateToPairCode(1L), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `repeated pairing code request emits a new inline pair code step`() = test { + sut.effects.test { + pairingCodeRequestId.value = 1L + assertEquals(HwConnectEffect.NavigateToPairCode(1L), awaitItem()) + pairingCodeRequestId.value = 2L + assertEquals(HwConnectEffect.NavigateToPairCode(2L), awaitItem()) cancelAndIgnoreRemainingEvents() } } diff --git a/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt b/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt index e02ad0a6f5..c3716d0a11 100644 --- a/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt +++ b/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt @@ -27,6 +27,13 @@ class TrezorErrorPresenterTest { assertEquals(deviceBusyMessage, message) } + @Test + fun `userMessage maps firmware error response to connect guidance`() { + val message = TrezorErrorPresenter.userMessage(context, AppError("Device error (code 99): Firmware error")) + + assertEquals(connectErrorMessage, message) + } + @Test fun `userMessage returns error message when present`() { assertEquals("connect failed", TrezorErrorPresenter.userMessage(context, AppError("connect failed"))) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f0ebda6e1c..0775139d8a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -132,6 +132,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val balanceState = MutableStateFlow(BalanceState()) private val hwReceivedTxs = MutableSharedFlow() private val needsPairingCode = MutableStateFlow(false) + private val pairingCodeRequestId = MutableStateFlow(null) private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) @@ -159,6 +160,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) @@ -296,22 +298,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `pairing code request shows and hides the pair device sheet`() = test { needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() - assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode), sut.currentSheet.value) + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(1L)), sut.currentSheet.value) needsPairingCode.value = false + pairingCodeRequestId.value = null advanceUntilIdle() assertNull(sut.currentSheet.value) } + @Test + fun `new app-wide pairing request replaces the stale pair code route`() = test { + needsPairingCode.value = true + pairingCodeRequestId.value = 1L + advanceUntilIdle() + + pairingCodeRequestId.value = 2L + advanceUntilIdle() + + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(2L)), sut.currentSheet.value) + } + @Test fun `pairing code request does not interrupt a high priority sheet`() = test { sut.showSheet(Sheet.Pin()) advanceUntilIdle() needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() assertEquals(Sheet.Pin(), sut.currentSheet.value) @@ -323,12 +340,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() sut.hideSheet() advanceUntilIdle() - assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode), sut.currentSheet.value) + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(1L)), sut.currentSheet.value) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index fb5486f698..8aa057420a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import com.synonym.bitkitcore.BroadcastException import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo @@ -9,21 +10,32 @@ import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeStatus import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R @@ -35,6 +47,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.Toast @@ -56,9 +69,11 @@ import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class TransferViewModelTest : BaseUnitTest() { private lateinit var sut: TransferViewModel @@ -221,13 +236,15 @@ class TransferViewModelTest : BaseUnitTest() { feeRate = FEE_RATE, totalSpent = order.feeSat + MINING_FEE, ) + val signed = signedFunding(funding) 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.success(broadcast)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -238,7 +255,8 @@ class TransferViewModelTest : BaseUnitTest() { eq(order.feeSat), eq(FEE_RATE), ) - verify(hwWalletRepo).signAndBroadcastFunding(eq(DEVICE_ID), eq(funding)) + verify(hwWalletRepo).signFunding(eq(DEVICE_ID), eq(funding)) + verify(hwWalletRepo).broadcastFunding(signed) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(transferRepo).createTransfer( eq(TransferType.TO_SPENDING), @@ -275,6 +293,7 @@ class TransferViewModelTest : BaseUnitTest() { feeRate = FALLBACK_FEE_RATE, totalSpent = order.feeSat + MINING_FEE, ) + val signed = signedFunding(funding, feeRate = FALLBACK_FEE_RATE) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) @@ -282,7 +301,8 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) .thenReturn(Result.failure(AppError("fee unavailable"))) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -301,7 +321,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) - .thenReturn(Result.failure(RuntimeException("no device"))) + .thenReturn(Result.failure(AppError("no device"))) whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -309,11 +329,57 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(DEVICE_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) } @Test - fun `onTransferToSpendingHwConfirm disconnects stale session when signing times out`() = test { + fun `cancelHardwareTransfer stops an in-flight hardware transfer`() = test { + val order = previewBtOrder() + val connectResult = CompletableDeferred>() + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)).doSuspendableAnswer { connectResult.await() } + whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + runCurrent() + assertEquals(true, sut.spendingUiState.value.isSigning) + + sut.cancelHardwareTransfer() + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.isSigning) + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + + @Test + fun `onTransferToSpendingHwConfirm shows connection guidance for bluetooth reconnect failure`() = test { + val order = previewBtOrder() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.failure(AppError("no device"))) + whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(true) + whenever(context.getString(R.string.hardware__connect_title)).thenReturn(CONNECT_TITLE) + whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(CONNECT_TITLE, toasts.single().title) + assertEquals(CONNECT_DESCRIPTION, toasts.single().description) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm disconnects stale session when signing fails with timeout`() = test { val order = previewBtOrder() val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException val funding = HwFundingTransaction( @@ -329,7 +395,7 @@ class TransferViewModelTest : BaseUnitTest() { .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(timeout)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.failure(timeout)) whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -339,6 +405,55 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm disconnects when HW_SIGN_TIMEOUT elapses`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + 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.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.signFunding(any(), any())).doSuspendableAnswer { + delay(Long.MAX_VALUE) + Result.success(signedFunding(funding)) + } + whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + + val viewModel = TransferViewModel( + context = context, + lightningRepo = lightningRepo, + blocktankRepo = blocktankRepo, + hwWalletRepo = hwWalletRepo, + walletRepo = walletRepo, + settingsStore = settingsStore, + cacheStore = cacheStore, + transferRepo = transferRepo, + clock = clock, + ) + + viewModel.onTransferToSpendingHwConfirm(order, DEVICE_ID) + runCurrent() + advanceTimeBy(120.seconds.inWholeMilliseconds + 1) + runCurrent() + advanceUntilIdle() + + verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } finally { + Dispatchers.resetMain() + } + } + @Test fun `onTransferToSpendingHwConfirm does not fund order when user cancels on device`() = test { val order = previewBtOrder() @@ -355,7 +470,7 @@ class TransferViewModelTest : BaseUnitTest() { .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())) + whenever(hwWalletRepo.signFunding(any(), any())) .thenReturn(Result.failure(TrezorException.UserCancelled())) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -382,9 +497,8 @@ class TransferViewModelTest : BaseUnitTest() { .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())) + whenever(hwWalletRepo.signFunding(any(), any())) .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.common__error)).thenReturn(ERROR_TITLE) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") @@ -392,10 +506,66 @@ class TransferViewModelTest : BaseUnitTest() { advanceUntilIdle() toastJob.cancel() + assertEquals(1, toasts.size) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm shows reconnect prompt for firmware error response`() = test { + val order = previewBtOrder() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = 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.failure(AppError("Device error (code 99): Firmware error"))) + whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)) + .thenReturn(RECONNECT_TITLE) + whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)) + .thenReturn(RECONNECT_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + assertEquals(1, toasts.size) assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(ERROR_TITLE, toasts.single().title) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().description) + assertEquals(RECONNECT_TITLE, toasts.single().title) + assertEquals(RECONNECT_DESCRIPTION, toasts.single().description) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm shows connection warning when composition times out`() = test { + val order = previewBtOrder() + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + 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.failure(timeout)) + whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) + whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(1, toasts.size) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) + assertEquals(CONNECTION_ISSUE_DESCRIPTION, toasts.single().description) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -408,7 +578,6 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.common__error)).thenReturn(ERROR_TITLE) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") @@ -417,9 +586,8 @@ class TransferViewModelTest : BaseUnitTest() { toastJob.cancel() assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(ERROR_TITLE, toasts.single().title) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().description) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) } @@ -433,8 +601,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(TrezorException.UserCancelled())) whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)).thenReturn("reconnect title") - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)).thenReturn("reconnect body") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_title) + ).thenReturn("reconnect title") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_description) + ).thenReturn("reconnect body") sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -444,6 +616,282 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm retries broadcast without signing again`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn( + Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed"))), + Result.success(broadcast), + ) + whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) + whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) + verify(cacheStore, never()).addPaidOrder(any(), any()) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo, times(1)).ensureConnected(DEVICE_ID) + verify(hwWalletRepo, times(1)).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(2)).broadcastFunding(signed) + verify(cacheStore).addPaidOrder(order.id, TXID) + } + + @Test + fun `onTransferToSpendingHwConfirm signs again when pending order address changes`() = test { + var order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) + whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) + whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + order = order.copy( + payment = requireNotNull(order.payment).copy( + onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), + ), + ) + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo, times(2)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo).composeFundingTransaction( + DEVICE_ID, + "bc1qnewdestination", + order.feeSat, + FEE_RATE, + ) + } + + @Test + fun `cancelHardwareTransfer keeps tracking after signing completes`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + val broadcastResult = CompletableDeferred>() + var hwTxSignedEmitted = false + backgroundScope.launch { + sut.transferEffects.collect { effect -> + if (effect is TransferEffect.OnHwTxSigned) { + hwTxSignedEmitted = 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).doSuspendableAnswer { broadcastResult.await() } + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + runCurrent() + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + + sut.cancelHardwareTransfer() + broadcastResult.complete(Result.success(broadcast)) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + assertTrue(hwTxSignedEmitted) + verify(cacheStore).addPaidOrder(order.id, TXID) + verify(transferRepo).createTransfer( + eq(TransferType.TO_SPENDING), + eq(order.clientBalanceSat.toLong()), + isNull(), + eq(TXID), + eq(order.id), + isNull(), + isNull(), + isNull(), + ) + } + + @Test + fun `onTransferToSpendingHwConfirm retries core-derived txid after bookkeeping fails`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) + var bookkeepingAttempts = 0 + whenever(cacheStore.addPaidOrder(order.id, TXID)).thenAnswer { + if (bookkeepingAttempts++ == 0) throw AppError("cache failed") + Unit + } + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo).broadcastFunding(signed) + verify(transferRepo, never()).createTransfer( + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(2)).broadcastFunding(signed) + verify(cacheStore, times(2)).addPaidOrder(order.id, TXID) + verify(transferRepo).createPendingToSpendingActivity(order, TXID, MINING_FEE, FEE_RATE) + } + + @Test + fun `onTransferToSpendingHwConfirm keeps signed transaction when broadcast times out`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(timeout)) + whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) + whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + verify(hwWalletRepo, never()).disconnectStaleSession(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm clears signed transaction after permanent broadcast failure`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + 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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(AppError("invalid transaction"))) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + private fun signedFunding( + funding: HwFundingTransaction, + feeRate: ULong = FEE_RATE, + ) = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = funding.miningFeeSats, + feeRate = feeRate, + totalSpent = funding.totalSpent, + ) + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", @@ -478,7 +926,12 @@ class TransferViewModelTest : BaseUnitTest() { const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE const val DEVICE_ID = "dev1" const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." - const val ERROR_TITLE = "Error" + const val CONNECTION_ISSUE_TITLE = "Internet Connectivity Issues" + const val CONNECTION_ISSUE_DESCRIPTION = "Please check your connection." + const val CONNECT_TITLE = "Connect Device" + const val CONNECT_DESCRIPTION = "Check the hardware device and try again." + const val RECONNECT_TITLE = "Reconnect Hardware Device" + const val RECONNECT_DESCRIPTION = "Please reconnect your hardware device." const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL diff --git a/changelog.d/next/1067.fixed.md b/changelog.d/next/1067.fixed.md new file mode 100644 index 0000000000..349a6902cd --- /dev/null +++ b/changelog.d/next/1067.fixed.md @@ -0,0 +1 @@ +Improved Trezor pairing, Bluetooth recovery, connection and unlock guidance, transfer cancellation, and signed-broadcast retries. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5aad7bc9b..f9c2d5b0b0 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.4.0" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.1" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc31" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }