diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c20fd33b3b..732c3b676e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -28,12 +28,12 @@
tools:ignore="ForegroundServicePermission,ForegroundServicesPolicy" />
-
+
-
+
@@ -150,7 +150,7 @@
-
+
diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt
index c127a13a68..307325d7eb 100644
--- a/app/src/main/java/to/bitkit/App.kt
+++ b/app/src/main/java/to/bitkit/App.kt
@@ -6,13 +6,18 @@ import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
+import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.env.Env
+import to.bitkit.repositories.HwWalletRepo
import to.bitkit.services.BluetoothInit
import to.bitkit.services.PubkyAuthHandlerRegistrar
import to.bitkit.utils.Logger
@@ -32,6 +37,10 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar
+ /** Resolved only once the process changes foreground state, so startup does not build the wallet graph. */
+ @Inject
+ lateinit var hwWalletRepo: Lazy
+
override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -47,6 +56,19 @@ internal open class App : Application(), Configuration.Provider {
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
+ observeAppForeground()
+ }
+
+ private fun observeAppForeground() {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(
+ LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_START -> hwWalletRepo.get().onAppForegrounded()
+ Lifecycle.Event.ON_STOP -> hwWalletRepo.get().onAppBackgrounded()
+ else -> Unit
+ }
+ },
+ )
}
private fun installUncaughtExceptionLogger() {
diff --git a/app/src/main/java/to/bitkit/data/HwWalletStore.kt b/app/src/main/java/to/bitkit/data/HwWalletStore.kt
index d65fa6ff47..5b67d2aea4 100644
--- a/app/src/main/java/to/bitkit/data/HwWalletStore.kt
+++ b/app/src/main/java/to/bitkit/data/HwWalletStore.kt
@@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.HwWalletDataSerializer
import to.bitkit.di.IoDispatcher
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.KnownDevice
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,22 +30,27 @@ class HwWalletStore @Inject constructor(
val data: Flow = store.data
- suspend fun loadKnownDevices(): List = withContext(ioDispatcher) {
- store.data.first().knownDevices
+ /** @param vendor when given, only that vendor's entries are returned. */
+ suspend fun loadKnownDevices(vendor: HwWalletVendor? = null): List = withContext(ioDispatcher) {
+ store.data.first().knownDevices.filter { vendor == null || it.vendor == vendor }
}
/**
* @param pendingName a pending-name change to apply in the same write, or null to leave them alone.
* Splitting the two would publish a device list without its matching name change, which restarts a
* watcher for a wallet already being removed and can leave a name in both places or in neither.
+ * @param vendor when given, [devices] replaces only that vendor's entries and the other vendors'
+ * entries are kept, so each vendor repo can write its own view without dropping the others'.
*/
suspend fun saveKnownDevices(
devices: List,
pendingName: PendingNameUpdate? = null,
+ vendor: HwWalletVendor? = null,
) = withContext(ioDispatcher) {
store.updateData { data ->
+ val kept = if (vendor == null) emptyList() else data.knownDevices.filter { it.vendor != vendor }
data.copy(
- knownDevices = devices,
+ knownDevices = kept + devices,
pendingNames = pendingName?.applyTo(data.pendingNames) ?: data.pendingNames,
)
}
diff --git a/app/src/main/java/to/bitkit/ext/HwExceptionExt.kt b/app/src/main/java/to/bitkit/ext/HwExceptionExt.kt
new file mode 100644
index 0000000000..ba7530e1d8
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ext/HwExceptionExt.kt
@@ -0,0 +1,10 @@
+package to.bitkit.ext
+
+/** Vendor-neutral views over the Trezor and Jade error predicates, for code shared by every vendor. */
+fun Throwable.isHwUserCancellation(): Boolean = isTrezorUserCancellation() || isJadeUserCancellation()
+
+fun Throwable.isHwDeviceBusy(): Boolean = isTrezorDeviceBusy() || isJadeDeviceBusy()
+
+fun Throwable.isHwFirmwareError(): Boolean = isTrezorFirmwareError() || isJadeFirmwareError()
+
+fun Throwable.isHwSessionFailure(): Boolean = isTrezorSessionFailure() || isJadeSessionFailure()
diff --git a/app/src/main/java/to/bitkit/ext/JadeExceptionExt.kt b/app/src/main/java/to/bitkit/ext/JadeExceptionExt.kt
new file mode 100644
index 0000000000..bb72fc8bb5
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ext/JadeExceptionExt.kt
@@ -0,0 +1,29 @@
+package to.bitkit.ext
+
+import com.synonym.bitkitcore.JadeException
+
+fun Throwable.isJadeUserCancellation(): Boolean =
+ generateSequence(this) { it.cause }.any { it is JadeException.UserCancelled }
+
+/** The device cannot serve the request until the user acts on it: busy with another prompt, or locked. */
+fun Throwable.isJadeDeviceBusy(): Boolean =
+ generateSequence(this) { it.cause }.any { it is JadeException.DeviceBusy || it is JadeException.DeviceLocked }
+
+fun Throwable.isJadeFirmwareError(): Boolean =
+ generateSequence(this) { it.cause }.any { it is JadeException.UnsupportedFirmware }
+
+fun Throwable.isJadeSessionFailure(): Boolean =
+ generateSequence(this) { it.cause }.any {
+ when (it) {
+ is JadeException.TransportException,
+ is JadeException.DeviceDisconnected,
+ is JadeException.ConnectionException,
+ is JadeException.Timeout,
+ is JadeException.NotConnected,
+ is JadeException.NotInitialized,
+ is JadeException.IoException,
+ -> true
+
+ else -> false
+ }
+ }
diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt
index df54fdc273..02ecb43a09 100644
--- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt
+++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt
@@ -5,9 +5,11 @@ import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.AddressType
+import com.synonym.bitkitcore.JadeAddressVariant
import com.synonym.bitkitcore.TrezorScriptType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.serialization.Serializable
@@ -24,6 +26,7 @@ data class HwWallet(
val fundingBalanceSats: ULong = balanceSats,
val deviceIds: ImmutableSet = persistentSetOf(id),
val passphraseProtected: Boolean = false,
+ val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
)
/** Serializable per-device balance snapshot carried by [BalanceState]. */
@@ -66,6 +69,16 @@ sealed interface HwFundingAccount {
override val accountType: AccountType
get() = addressType.accountType
}
+
+ data class Jade(
+ override val xpub: String,
+ override val addressType: HwFundingAddressType,
+ override val balanceSats: ULong,
+ ) : HwFundingAccount {
+ override val vendor: HwWalletVendor = HwWalletVendor.BLOCKSTREAM
+ override val accountType: AccountType
+ get() = addressType.accountType
+ }
}
data class HwFundingTransaction(
@@ -90,8 +103,56 @@ data class HwFundingBroadcastResult(
val totalSpent: ULong,
)
-enum class HwWalletVendor {
- TREZOR,
+/**
+ * Hardware wallet makers Bitkit can pair with. [deviceType] is the wallet-id namespace passed to
+ * bitkit-core's `deriveWalletId`, so it must stay stable once entries are persisted.
+ */
+enum class HwWalletVendor(val deviceType: String) {
+ TREZOR("trezor"),
+ BLOCKSTREAM("jade"),
+}
+
+/** A device found by discovery that is not paired yet, across every vendor. */
+@Immutable
+data class HwNearbyDevice(
+ val vendor: HwWalletVendor,
+ val id: String,
+ val path: String,
+ val transportType: TransportType,
+ val name: String? = null,
+ val model: String? = null,
+)
+
+/** The device holding the live session, across every vendor. */
+@Immutable
+data class HwConnectedDevice(
+ val vendor: HwWalletVendor,
+ val id: String,
+ val label: String? = null,
+ val model: String? = null,
+ /** Identity the live session was opened for; a Trezor can hold several passphrase wallets. */
+ val walletId: String? = null,
+ val passphraseProtection: Boolean = false,
+ /** The device needs its PIN before it can sign; a Jade locks on every power cycle. */
+ val isLocked: Boolean = false,
+)
+
+/** Discovery and connection state of every hardware-wallet vendor, merged for the UI. */
+@Immutable
+data class HwDeviceState(
+ val isScanning: Boolean = false,
+ val isConnecting: Boolean = false,
+ val isAutoReconnecting: Boolean = false,
+ /** A Jade is waiting for its PIN to be entered on the device. */
+ val isUnlocking: Boolean = false,
+ val knownDevices: ImmutableList = persistentListOf(),
+ val nearbyDevices: ImmutableList = persistentListOf(),
+ val connected: HwConnectedDevice? = null,
+ val error: String? = null,
+) {
+ fun connectedDeviceId(): String? = connected?.id
+
+ fun connectedWalletId(): String? = connected?.walletId
}
enum class HwFundingAddressType(
@@ -116,8 +177,19 @@ enum class HwFundingAddressType(
TAPROOT -> TrezorScriptType.SPEND_TAPROOT
}
+ val jadeVariant: JadeAddressVariant
+ get() = when (this) {
+ LEGACY -> JadeAddressVariant.PKH
+ NESTED_SEGWIT -> JadeAddressVariant.SH_WPKH
+ NATIVE_SEGWIT -> JadeAddressVariant.WPKH
+ TAPROOT -> JadeAddressVariant.TR
+ }
+
companion object {
val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE }
+
+ fun fromJadeVariant(variant: JadeAddressVariant): HwFundingAddressType =
+ entries.first { it.jadeVariant == variant }
}
}
diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt
index 9e20dce97d..1583742761 100644
--- a/app/src/main/java/to/bitkit/models/KnownDevice.kt
+++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt
@@ -30,4 +30,73 @@ data class KnownDevice(
* that report a different one belong to a seed the device can no longer sign for.
*/
val trezorDeviceId: String? = null,
-)
+ /** Entries stored before other vendors existed carry no vendor and are Trezor ones. */
+ val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
+ /** The Jade's efuse MAC: the one identifier that survives a USB replug, which renumbers [path]. */
+ val jadeDeviceId: String? = null,
+) {
+ /** The vendor's own stable device identifier, when the device reported one. */
+ val hardwareId: String?
+ get() = when (vendor) {
+ HwWalletVendor.TREZOR -> trezorDeviceId
+ HwWalletVendor.BLOCKSTREAM -> jadeDeviceId
+ }
+}
+
+internal fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId
+
+/**
+ * Cross-transport identity of the wallet a device entry tracks: entries created by pairing the same
+ * physical device over different transports share the same xpubs. Entries without captured xpubs fall
+ * back to their own transport-level id.
+ */
+internal val KnownDevice.walletKey: String
+ get() = walletKey(xpubs, id)
+
+internal fun walletKey(xpubs: Map, fallback: String): String =
+ xpubs.values.sorted().joinToString().ifEmpty { fallback }
+
+/**
+ * Whether a stored entry gives way to the one just read. That covers the identity it holds and the
+ * entry this connect refreshed, since reading a previously rejected address type changes the
+ * walletKey and matching on the new key alone would leave the old entry behind as a duplicate.
+ * Wallets of a seed the device no longer carries go too: nothing would ever supersede them by key
+ * material. An unknown device id proves nothing, so those entries are left alone.
+ */
+internal fun KnownDevice.isReplacedBy(known: KnownDevice, refreshed: KnownDevice?): Boolean {
+ if (id != known.id) return false
+ if (walletKey == known.walletKey) return true
+ if (refreshed != null && walletKey == refreshed.walletKey) return true
+ return known.hardwareId != null && hardwareId != null && hardwareId != known.hardwareId
+}
+
+internal fun deriveHardwareWalletId(xpubs: Map, vendor: HwWalletVendor): String? =
+ if (xpubs.isEmpty()) {
+ null
+ } else {
+ runCatching { HwWalletId.derive(xpubs, deviceType = vendor.deviceType) }.getOrNull()
+ }
+
+internal fun List.findHardwareWalletId(
+ xpubs: Map,
+ fallback: String,
+ vendor: HwWalletVendor,
+): String {
+ val walletKey = walletKey(xpubs, fallback)
+ return firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }
+ ?: deriveHardwareWalletId(xpubs, vendor).orEmpty()
+}
+
+internal fun List.withHardwareWalletIds(): List {
+ val existingByWallet = filter { it.walletId.isNotBlank() }
+ .associate { it.walletKey to it.walletId }
+ val generatedByWallet = mutableMapOf()
+
+ return map {
+ val walletId = existingByWallet[it.walletKey]
+ ?: generatedByWallet.getOrPut(it.walletKey) {
+ deriveHardwareWalletId(it.xpubs, it.vendor).orEmpty()
+ }
+ if (it.walletId == walletId) it else it.copy(walletId = walletId)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/models/Network.kt b/app/src/main/java/to/bitkit/models/Network.kt
index bb95280d91..e3b10d01e8 100644
--- a/app/src/main/java/to/bitkit/models/Network.kt
+++ b/app/src/main/java/to/bitkit/models/Network.kt
@@ -1,8 +1,10 @@
package to.bitkit.models
+import com.synonym.bitkitcore.JadeNetwork
import com.synonym.bitkitcore.NetworkType
import com.synonym.bitkitcore.TrezorCoinType
import org.lightningdevkit.ldknode.Network
+import to.bitkit.utils.AppError
import com.synonym.bitkitcore.Network as BitkitCoreNetwork
fun Network.networkUiText(): String = when (this) {
@@ -19,6 +21,14 @@ fun Network.toTrezorCoinType(): TrezorCoinType = when (this) {
Network.REGTEST -> TrezorCoinType.REGTEST
}
+/** Jade has no signet; its regtest is named "localtest" on the wire and is mapped by bitkit-core. */
+fun Network.toJadeNetwork(): JadeNetwork = when (this) {
+ Network.BITCOIN -> JadeNetwork.MAINNET
+ Network.TESTNET -> JadeNetwork.TESTNET
+ Network.REGTEST -> JadeNetwork.REGTEST
+ Network.SIGNET -> throw AppError("Signet is not supported by Jade")
+}
+
fun Network.toCoreNetwork(): BitkitCoreNetwork = when (this) {
Network.BITCOIN -> BitkitCoreNetwork.BITCOIN
Network.TESTNET -> BitkitCoreNetwork.TESTNET
diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
index 8e804a1204..29f2798616 100644
--- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
@@ -4,9 +4,9 @@ import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.CoinSelection
import com.synonym.bitkitcore.ComposeOutput
import com.synonym.bitkitcore.ComposeResult
+import com.synonym.bitkitcore.JadeException
import com.synonym.bitkitcore.PaymentType
import com.synonym.bitkitcore.TransactionDetails
-import com.synonym.bitkitcore.TrezorDeviceInfo
import com.synonym.bitkitcore.TrezorFeatures
import com.synonym.bitkitcore.WatcherEvent
import kotlinx.collections.immutable.ImmutableList
@@ -38,19 +38,23 @@ import to.bitkit.data.PendingNameUpdate
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
-import to.bitkit.ext.isTrezorSessionFailure
+import to.bitkit.ext.isHwSessionFailure
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.scopedId
import to.bitkit.ext.timestamp
import to.bitkit.ext.walletId
+import to.bitkit.models.HwConnectedDevice
+import to.bitkit.models.HwDeviceState
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.HwNearbyDevice
import to.bitkit.models.HwReceiveAddress
import to.bitkit.models.HwWallet
import to.bitkit.models.HwWalletReceivedTx
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.KnownDevice
import to.bitkit.models.TransportType
import to.bitkit.models.WalletScope
@@ -59,26 +63,31 @@ import to.bitkit.models.toAccountType
import to.bitkit.models.toAddressType
import to.bitkit.models.toCoreNetwork
import to.bitkit.models.toTrezorCoinType
+import to.bitkit.models.walletKey
import to.bitkit.services.TrezorWalletMode
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.ceil
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
- * Production hardware-wallet business layer. Tracks paired Trezor devices as
- * watch-only balances by running one on-chain xpub watcher per (device, address type)
- * and exposing the aggregated per-device balance and activity to the UI.
+ * Production hardware-wallet business layer. Tracks paired devices of every vendor as
+ * watch-only balances by running one on-chain xpub watcher per (wallet, address type)
+ * and exposing the aggregated per-wallet balance and activity to the UI.
*
- * Built on top of [TrezorRepo], which owns the device list, connect orchestration
- * and the underlying watcher transport.
+ * Device sessions are owned per vendor by [TrezorRepo] and [JadeRepo]; every call that
+ * touches a device is routed by the vendor of the wallet's stored entry. The watcher and
+ * on-chain transport is vendor neutral and lives in [TrezorRepo].
*/
-@Suppress("LargeClass", "TooManyFunctions")
+@Suppress("LargeClass", "TooManyFunctions", "LongParameterList")
@Singleton
class HwWalletRepo @Inject constructor(
private val trezorRepo: TrezorRepo,
+ private val jadeRepo: JadeRepo,
private val activityRepo: ActivityRepo,
private val preActivityMetadataRepo: PreActivityMetadataRepo,
private val hwWalletStore: HwWalletStore,
@@ -93,8 +102,15 @@ class HwWalletRepo @Inject constructor(
/** Trezor v1 (2.4.0) tracks native SegWit accounts. */
private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey)
+
+ /** A Trezor reconnect is a session handshake; a Jade one may include entering the PIN on the device. */
+ private val TREZOR_RECONNECT_TIMEOUT = 30.seconds
+ private val JADE_RECONNECT_TIMEOUT = 5.minutes
}
+ /** Alternates which vendor gets the Bluetooth part of a scan, to stay under Android's scan-rate limit. */
+ private var scanBluetoothVendor = HwWalletVendor.TREZOR
+
private val scope = appScope(ioDispatcher, TAG)
private val watcherMutex = Mutex()
@@ -115,16 +131,39 @@ class HwWalletRepo @Inject constructor(
val receivedTxs: SharedFlow = _receivedTxs.asSharedFlow()
/** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */
- fun onTransportRestored(transportType: TransportType) = trezorRepo.onTransportRestored(transportType)
+ fun onTransportRestored(transportType: TransportType, vendor: HwWalletVendor? = null) {
+ if (vendor != HwWalletVendor.BLOCKSTREAM) trezorRepo.onTransportRestored(transportType)
+ if (vendor != HwWalletVendor.TREZOR) jadeRepo.onTransportRestored(transportType)
+ }
+
+ fun onAppForegrounded() {
+ trezorRepo.onAppForegrounded()
+ jadeRepo.onAppForegrounded()
+ }
- fun onAppForegrounded() = trezorRepo.onAppForegrounded()
+ /** The whole app left the foreground; a Jade releases its Bluetooth link after a grace period. */
+ fun onAppBackgrounded() = jadeRepo.onAppBackgrounded()
fun warmUpKnownDevice(walletId: String) {
scope.launch {
- transportDeviceIdOrNull(walletId)?.let { trezorRepo.warmUpKnownDevice(it) }
+ val deviceId = transportDeviceIdOrNull(walletId) ?: return@launch
+ when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> trezorRepo.warmUpKnownDevice(deviceId)
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.warmUpKnownDevice(deviceId)
+ }
}
}
+ /** The vendor of the device tracking [walletId]; entries stored before Jade existed are Trezor ones. */
+ private suspend fun vendorOf(walletId: String): HwWalletVendor =
+ devicesForWallet(walletId).firstOrNull()?.vendor ?: HwWalletVendor.TREZOR
+
+ /** How long a reconnect may take before the UI gives up; a Jade may be waiting for its PIN. */
+ suspend fun reconnectTimeout(walletId: String): Duration = when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> TREZOR_RECONNECT_TIMEOUT
+ HwWalletVendor.BLOCKSTREAM -> JADE_RECONNECT_TIMEOUT
+ }
+
/**
* Entries tracking one wallet identity. A physical device holds the standard wallet plus one
* entry per passphrase wallet, and each of those is stored once per transport it paired over.
@@ -135,7 +174,7 @@ class HwWalletRepo @Inject constructor(
/** Transport-level id to reach [walletId] with: the connected entry, else the most recent one. */
private suspend fun transportDeviceIdOrNull(walletId: String): String? {
val devices = devicesForWallet(walletId)
- val connectedId = trezorRepo.state.value.connectedDeviceId()
+ val connectedId = deviceState.value.connectedDeviceId()
return devices.find { it.id == connectedId }?.id ?: devices.maxByOrNull { it.lastConnectedAt }?.id
}
@@ -159,6 +198,7 @@ class HwWalletRepo @Inject constructor(
_watcherData.update { emptyMap() }
}
trezorRepo.resetState()
+ jadeRepo.resetState()
}
/** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */
@@ -171,22 +211,75 @@ class HwWalletRepo @Inject constructor(
fun cancelPairingCode() = trezorRepo.cancelPairingCode()
- /** Device discovery and connection state used by the Connect Hardware flow. */
- val deviceState: StateFlow = trezorRepo.state
+ /** Device discovery and connection state of every vendor, merged for the Connect Hardware flow. */
+ val deviceState: StateFlow = combine(trezorRepo.state, jadeRepo.state) { trezor, jade ->
+ val trezorState = trezor.toHwDeviceState()
+ val jadeState = jade.toHwDeviceState()
+ HwDeviceState(
+ isScanning = trezorState.isScanning || jadeState.isScanning,
+ isConnecting = trezorState.isConnecting || jadeState.isConnecting,
+ isAutoReconnecting = trezorState.isAutoReconnecting || jadeState.isAutoReconnecting,
+ isUnlocking = jadeState.isUnlocking,
+ knownDevices = (trezorState.knownDevices + jadeState.knownDevices).toImmutableList(),
+ nearbyDevices = (trezorState.nearbyDevices + jadeState.nearbyDevices).toImmutableList(),
+ connected = trezorState.connected ?: jadeState.connected,
+ error = trezorState.error ?: jadeState.error,
+ )
+ }.stateIn(scope, SharingStarted.Eagerly, HwDeviceState())
- /** Scans for nearby unpaired devices; results land in [deviceState]'s nearbyDevices. */
+ /**
+ * Scans every vendor for nearby unpaired devices; results land in [deviceState]'s nearbyDevices.
+ * USB is enumerated for both vendors every time, while the Bluetooth part alternates between
+ * them: Android throttles apps that start scans too often, and a scan drops any open Bluetooth
+ * link, so none runs while either vendor holds one. Succeeds when either vendor's scan does.
+ */
suspend fun scan(
includeBluetooth: Boolean = true,
- ): Result> = trezorRepo.scan(
- includeBluetooth = includeBluetooth,
- )
+ ): Result> = withContext(ioDispatcher) {
+ val bluetoothVendor = scanBluetoothVendor
+ scanBluetoothVendor = when (bluetoothVendor) {
+ HwWalletVendor.TREZOR -> HwWalletVendor.BLOCKSTREAM
+ HwWalletVendor.BLOCKSTREAM -> HwWalletVendor.TREZOR
+ }
+ val bluetoothFree = includeBluetooth && !hasOpenBluetoothSession()
+ val trezor = trezorRepo.scan(includeBluetooth = bluetoothFree && bluetoothVendor == HwWalletVendor.TREZOR)
+ val jade = jadeRepo.scan(includeBluetooth = bluetoothFree && bluetoothVendor == HwWalletVendor.BLOCKSTREAM)
+ if (trezor.isFailure && jade.isFailure) {
+ return@withContext Result.failure(checkNotNull(trezor.exceptionOrNull()))
+ }
+ val found = trezor.getOrDefault(emptyList()).map { it.toHwNearbyDevice() } +
+ jade.getOrDefault(emptyList()).map { it.toHwNearbyDevice() }
+ Result.success(found)
+ }
+
+ private suspend fun hasOpenBluetoothSession(): Boolean {
+ val connected = deviceState.value.connected ?: return false
+ return devicesForDeviceId(connected.id).any { it.transportType == TransportType.BLUETOOTH } ||
+ connected.id.startsWith("ble:")
+ }
+
+ private suspend fun devicesForDeviceId(deviceId: String): List =
+ hwWalletStore.loadKnownDevices().filter { it.id == deviceId || it.path == deviceId }
- suspend fun hasKnownDevice(deviceId: String): Boolean = trezorRepo.hasKnownDevice(deviceId)
+ suspend fun hasKnownDevice(deviceId: String, vendor: HwWalletVendor? = null): Boolean = when (vendor) {
+ HwWalletVendor.TREZOR -> trezorRepo.hasKnownDevice(deviceId)
+ // A USB path is renumbered on every plug, so any paired USB Jade claims a plugged-in one.
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.hasKnownUsbDevice(deviceId)
+ null -> trezorRepo.hasKnownDevice(deviceId) || jadeRepo.hasKnownDevice(deviceId)
+ }
/** Connects and pairs a discovered device, persisting it as a watch-only known device. */
- suspend fun connect(deviceId: String): Result {
- trezorRepo.resetWalletSelection()
- return trezorRepo.connect(deviceId)
+ suspend fun connect(
+ deviceId: String,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
+ ): Result = when (vendor) {
+ HwWalletVendor.TREZOR -> {
+ trezorRepo.resetWalletSelection()
+ trezorRepo.connect(deviceId).map { features ->
+ trezorRepo.state.value.connected?.toHwConnectedDevice() ?: features.toHwConnectedDevice(deviceId)
+ }
+ }
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.connect(deviceId).map { it.toHwConnectedDevice() }
}
/**
@@ -205,6 +298,7 @@ class HwWalletRepo @Inject constructor(
// user retyping a passphrase that can never take effect. Only the device can say
// that though: with no session there is nothing to ask, and sending the user to
// enable a setting they already have on helps nobody.
+ if (jadeRepo.state.value.connected?.matches(deviceId) == true) throw HwPassphraseDisabledError()
val features = trezorRepo.state.value.connectedDevice()
?: throw AppError("Lost the session with device '$deviceId' before reading its wallet")
if (features.passphraseProtection != true) throw HwPassphraseDisabledError()
@@ -222,9 +316,17 @@ class HwWalletRepo @Inject constructor(
suspend fun reconnect(
walletId: String,
forceSession: Boolean = false,
- ): Result = withContext(ioDispatcher) {
+ ): Result = withContext(ioDispatcher) {
runSuspendCatching {
- trezorRepo.connectKnownDevice(transportDeviceId(walletId), forceSession = forceSession).getOrThrow()
+ val deviceId = transportDeviceId(walletId)
+ when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession)
+ .getOrThrow()
+ .toHwConnectedDevice(deviceId)
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.connectKnownDevice(deviceId, forceSession = forceSession)
+ .getOrThrow()
+ .toHwConnectedDevice()
+ }
}
}
@@ -234,10 +336,18 @@ class HwWalletRepo @Inject constructor(
* otherwise be accepted and sign with the wrong seed. The standard wallet needs no secret to
* reopen; a passphrase wallet does, which the caller has to collect.
*/
- suspend fun ensureConnected(walletId: String): Result = withContext(ioDispatcher) {
+ suspend fun ensureConnected(walletId: String): Result = withContext(ioDispatcher) {
runSuspendCatching {
val deviceId = transportDeviceId(walletId)
- val features = trezorRepo.ensureConnected(deviceId).getOrThrow()
+ if (vendorOf(walletId) == HwWalletVendor.BLOCKSTREAM) {
+ val connected = jadeRepo.ensureConnected(deviceId).getOrThrow()
+ val opened = connected.walletId
+ if (opened != null && opened != walletId) {
+ throw AppError("Device '$deviceId' is not holding wallet '$walletId'")
+ }
+ return@runSuspendCatching connected.toHwConnectedDevice()
+ }
+ val features = trezorRepo.ensureConnected(deviceId).getOrThrow().toHwConnectedDevice(deviceId)
if (trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) {
return@runSuspendCatching features
}
@@ -251,7 +361,7 @@ class HwWalletRepo @Inject constructor(
// device simply is not holding it, which is a reconnect failure.
throw AppError("Device '$deviceId' is not holding wallet '$walletId'")
}
- reopened
+ reopened.toHwConnectedDevice(deviceId)
}
}
@@ -274,6 +384,10 @@ class HwWalletRepo @Inject constructor(
devices.any { it.passphraseProtected } && trezorRepo.state.value.connectedWalletId() != walletId
}
+ private fun HwWalletVendor.requirePassphraseSupport() {
+ if (this == HwWalletVendor.BLOCKSTREAM) throw HwPassphraseDisabledError()
+ }
+
/**
* Reopens a watched passphrase wallet for signing. A wrong passphrase is not rejected by the
* device — it silently derives a different wallet — so the reopened session is only accepted
@@ -283,6 +397,7 @@ class HwWalletRepo @Inject constructor(
suspend fun reconnectWithPassphrase(walletId: String, passphrase: String): Result =
withContext(ioDispatcher) {
runSuspendCatching {
+ vendorOf(walletId).requirePassphraseSupport()
val deviceId = transportDeviceId(walletId)
val watchedBefore = hwWalletStore.loadKnownDevices().mapNotNull { it.resolvedWalletId() }.toSet()
// Not setWalletMode: the session this reopens is usually already gone, either
@@ -315,7 +430,10 @@ class HwWalletRepo @Inject constructor(
suspend fun isKnownBluetoothDevice(walletId: String): Boolean = withContext(ioDispatcher) {
val deviceId = transportDeviceIdOrNull(walletId) ?: return@withContext false
- trezorRepo.isKnownBluetoothDevice(deviceId)
+ when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> trezorRepo.isKnownBluetoothDevice(deviceId)
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.isKnownBluetoothDevice(deviceId)
+ }
}
suspend fun getFundingAccount(
@@ -334,11 +452,18 @@ class HwWalletRepo @Inject constructor(
.values
.filter { it.addressType == addressType && it.walletId == walletId }
.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }
- HwFundingAccount.Trezor(
- xpub = xpub,
- addressType = addressType,
- balanceSats = balanceSats,
- )
+ when (target.vendor) {
+ HwWalletVendor.TREZOR -> HwFundingAccount.Trezor(
+ xpub = xpub,
+ addressType = addressType,
+ balanceSats = balanceSats,
+ )
+ HwWalletVendor.BLOCKSTREAM -> HwFundingAccount.Jade(
+ xpub = xpub,
+ addressType = addressType,
+ balanceSats = balanceSats,
+ )
+ }
}
}
@@ -384,6 +509,9 @@ class HwWalletRepo @Inject constructor(
walletId: String,
receiveAddress: HwReceiveAddress,
): Result = withContext(ioDispatcher) {
+ if (vendorOf(walletId) == HwWalletVendor.BLOCKSTREAM) {
+ return@withContext verifyJadeReceiveAddress(walletId, receiveAddress)
+ }
runSuspendCatching {
suspend fun readOnDevice() = trezorRepo.getAddress(
path = receiveAddress.path,
@@ -398,12 +526,12 @@ class HwWalletRepo @Inject constructor(
val response = if (firstError == null) {
firstAttempt.getOrThrow()
} else {
- if (!firstError.isTrezorSessionFailure()) throw firstError
+ if (!firstError.isHwSessionFailure()) throw firstError
disconnectStaleSession(walletId).getOrThrow()
ensureConnected(walletId).getOrThrow()
runSuspendCatching { readOnDevice() }
.onFailure {
- if (it.isTrezorSessionFailure()) {
+ if (it.isHwSessionFailure()) {
disconnectStaleSession(walletId).getOrThrow()
}
}
@@ -418,6 +546,37 @@ class HwWalletRepo @Inject constructor(
}
}
+ /** Jade compares on the device itself: it shows the address and answers with a mismatch error. */
+ private suspend fun verifyJadeReceiveAddress(
+ walletId: String,
+ receiveAddress: HwReceiveAddress,
+ ): Result = runSuspendCatching {
+ suspend fun verifyOnDevice() = jadeRepo.verifyAddress(
+ addressType = receiveAddress.addressType,
+ derivationPath = receiveAddress.path,
+ expectedAddress = receiveAddress.address,
+ ).getOrThrow()
+
+ ensureConnected(walletId).getOrThrow()
+ runSuspendCatching { verifyOnDevice() }
+ .recoverCatching { error ->
+ if (!error.isHwSessionFailure()) throw error
+ disconnectStaleSession(walletId).getOrThrow()
+ ensureConnected(walletId).getOrThrow()
+ runSuspendCatching { verifyOnDevice() }
+ .onFailure { if (it.isHwSessionFailure()) disconnectStaleSession(walletId).getOrThrow() }
+ .getOrThrow()
+ }
+ .recoverCatching { error ->
+ if (error !is JadeException.AddressMismatch) throw error
+ throw HwReceiveAddressMismatchError(
+ "Address verification failed: Jade returned '${error.returned}' for " +
+ "'${receiveAddress.path}', expected '${receiveAddress.address}'"
+ )
+ }
+ .getOrThrow()
+ }
+
/** Composes the exact on-chain funding payment before prompting for the Trezor signature. */
suspend fun composeFundingTransaction(
walletId: String,
@@ -428,14 +587,31 @@ class HwWalletRepo @Inject constructor(
runSuspendCatching {
val account = getFundingAccount(walletId).getOrThrow()
val network = Env.network.toCoreNetwork()
- val composed = trezorRepo.composeTransaction(
- extendedKey = account.xpub,
- outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)),
- feeRates = listOf(satsPerVByte.toFloat()),
- network = network,
- accountType = account.accountType,
- coinSelection = CoinSelection.BRANCH_AND_BOUND,
- ).getOrThrow()
+ val outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats))
+ val composed = when (account) {
+ is HwFundingAccount.Trezor -> trezorRepo.composeTransaction(
+ extendedKey = account.xpub,
+ outputs = outputs,
+ feeRates = listOf(satsPerVByte.toFloat()),
+ network = network,
+ accountType = account.accountType,
+ coinSelection = CoinSelection.BRANCH_AND_BOUND,
+ ).getOrThrow()
+ // The PSBT must carry the Jade's key origins, or the device signs nothing.
+ is HwFundingAccount.Jade -> {
+ ensureConnected(walletId).getOrThrow()
+ val fingerprint = jadeRepo.getMasterFingerprint().getOrThrow()
+ trezorRepo.composeTransactionOffline(
+ extendedKey = account.xpub,
+ outputs = outputs,
+ feeRates = listOf(satsPerVByte.toFloat()),
+ network = network,
+ accountType = account.accountType,
+ coinSelection = CoinSelection.BRANCH_AND_BOUND,
+ fingerprint = fingerprint,
+ ).getOrThrow()
+ }
+ }
val success = composed.filterIsInstance().firstOrNull()
?: throw AppError(
composed.filterIsInstance().firstOrNull()?.error
@@ -504,28 +680,21 @@ class HwWalletRepo @Inject constructor(
)
}
- /** Signs a composed funding payment on the Trezor. */
+ /** Signs a composed funding payment on the device. */
suspend fun signFunding(
walletId: String,
funding: HwFundingTransaction,
): Result = withContext(ioDispatcher) {
runSuspendCatching {
- // The session can change between connecting and signing, and signing the wrong seed
- // would produce signatures that do not match the inputs being spent.
- if (!trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) {
- throw HwPassphraseRequiredError()
- }
- val signedTx = trezorRepo.signTxFromPsbt(
- psbtBase64 = funding.psbt,
- network = Env.network.toTrezorCoinType(),
- ).getOrElse {
- if (it.isTrezorSessionFailure()) {
- transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) }
- }
- throw it
+ val serializedTx = when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> signTrezorFunding(walletId, funding)
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.signPsbt(funding.psbt).getOrElse {
+ if (it.isHwSessionFailure()) disconnectStaleSession(walletId)
+ throw it
+ }.serializedTx
}
HwFundingSignedTx(
- serializedTx = signedTx.serializedTx,
+ serializedTx = serializedTx,
miningFeeSats = funding.miningFeeSats,
feeRate = ceil(funding.feeRate.toDouble()).toULong(),
totalSpent = funding.totalSpent,
@@ -533,6 +702,23 @@ class HwWalletRepo @Inject constructor(
}
}
+ private suspend fun signTrezorFunding(walletId: String, funding: HwFundingTransaction): String {
+ // The session can change between connecting and signing, and signing the wrong seed
+ // would produce signatures that do not match the inputs being spent.
+ if (!trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) {
+ throw HwPassphraseRequiredError()
+ }
+ return trezorRepo.signTxFromPsbt(
+ psbtBase64 = funding.psbt,
+ network = Env.network.toTrezorCoinType(),
+ ).getOrElse {
+ if (it.isHwSessionFailure()) {
+ transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) }
+ }
+ throw it
+ }.serializedTx
+ }
+
/** Broadcasts a signed funding payment without requiring the hardware device. */
suspend fun broadcastFunding(
signedTx: HwFundingSignedTx,
@@ -551,7 +737,10 @@ class HwWalletRepo @Inject constructor(
suspend fun disconnectStaleSession(walletId: String): Result = withContext(ioDispatcher) {
runSuspendCatching {
val deviceId = transportDeviceIdOrNull(walletId) ?: return@runSuspendCatching
- trezorRepo.disconnectStaleSession(deviceId).getOrThrow()
+ when (vendorOf(walletId)) {
+ HwWalletVendor.TREZOR -> trezorRepo.disconnectStaleSession(deviceId).getOrThrow()
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.disconnectStaleSession(deviceId).getOrThrow()
+ }
}
}
@@ -627,11 +816,19 @@ class HwWalletRepo @Inject constructor(
// store never publishes a device list still holding this wallet. A separate write would,
// and a reconcile reading it restarts the watcher of the wallet being removed.
val failures = targets.mapNotNull { device ->
- trezorRepo.forgetDevice(
- device.id,
- walletKey = device.walletKey,
- pendingName = PendingNameUpdate(walletId, keptName),
- ).exceptionOrNull()
+ val pendingName = PendingNameUpdate(walletId, keptName)
+ when (device.vendor) {
+ HwWalletVendor.TREZOR -> trezorRepo.forgetDevice(
+ device.id,
+ walletKey = device.walletKey,
+ pendingName = pendingName,
+ )
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.forgetDevice(
+ device.id,
+ walletKey = device.walletKey,
+ pendingName = pendingName,
+ )
+ }.exceptionOrNull()
}
val remaining = hwWalletStore.loadKnownDevices()
failures.firstOrNull()?.let { throw it }
@@ -646,9 +843,9 @@ class HwWalletRepo @Inject constructor(
val wallets: StateFlow> = combine(
hwWalletStore.data,
- trezorRepo.state,
+ deviceState,
_watcherData,
- ) { data, trezorState, watcherData ->
+ ) { data, hwState, watcherData ->
// The same physical device paired over both bluetooth and usb is stored as two
// entries with different transport-level ids; its xpubs are the cross-transport
// identity, so group by them to show one wallet and count its balance once. A
@@ -658,7 +855,7 @@ class HwWalletRepo @Inject constructor(
.groupBy { it.walletKey }
.mapNotNull { (_, devices) ->
val walletId = devices.firstNotNullOfOrNull { it.resolvedWalletId() } ?: return@mapNotNull null
- val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() }
+ val connectedDevice = devices.find { it.id == hwState.connectedDeviceId() }
val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt }
val ids = devices.map { it.id }.toSet()
val walletWatchers = watcherData.values.filter { it.walletId == walletId }
@@ -674,7 +871,7 @@ class HwWalletRepo @Inject constructor(
// them, and only that identity can sign; mark the others disconnected. Sessions
// opened before an identity was resolved report no wallet and stay inclusive.
isConnected = connectedDevice != null &&
- trezorState.connectedWalletId().let { it == null || it == walletId },
+ hwState.connectedWalletId().let { it == null || it == walletId },
balanceSats = walletWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats },
activities = walletWatchers
.toMergedActivities()
@@ -682,6 +879,7 @@ class HwWalletRepo @Inject constructor(
fundingBalanceSats = fundingBalanceSats,
deviceIds = ids.toImmutableSet(),
passphraseProtected = devices.any { it.passphraseProtected },
+ vendor = device.vendor,
)
}
.toImmutableList()
@@ -921,8 +1119,10 @@ class HwWalletRepo @Inject constructor(
}
}
- private fun KnownDevice.resolvedWalletId(): String? =
- walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs)
+ private fun KnownDevice.resolvedWalletId(): String? = walletId.takeIf { it.isNotBlank() } ?: when (vendor) {
+ HwWalletVendor.TREZOR -> trezorRepo.deriveWalletId(xpubs)
+ HwWalletVendor.BLOCKSTREAM -> jadeRepo.deriveWalletId(xpubs)
+ }
private fun List.toMergedActivities(): List =
flatMap { it.activities }
@@ -1004,28 +1204,37 @@ private data class WatcherSettings(
val electrumUrl: String,
)
-/**
- * Cross-transport identity of the wallet a device entry tracks: entries created by
- * pairing the same physical device over different transports share the same xpubs.
- * Entries without captured xpubs fall back to their own transport-level id.
- */
-private val KnownDevice.walletKey: String
- get() = xpubs.values.sorted().joinToString().ifEmpty { id }
-
/**
* Resolves the name shown for a hardware wallet: the Bitkit-side custom label if the user set one,
* otherwise the device's own label; without one (or with the factory default that just mirrors the
- * model) it falls back to the vendor-prefixed model (e.g. "Safe 7" reads as "Trezor Safe 7").
+ * model) it falls back to the vendor-prefixed model (e.g. "Safe 7" reads as "Trezor Safe 7"). Jade
+ * models already carry their name ("Jade", "Jade Plus") and a Jade has no label of its own.
*/
-fun resolveHwWalletName(label: String?, model: String?, customLabel: String? = null): String {
+fun resolveHwWalletName(
+ label: String?,
+ model: String?,
+ customLabel: String? = null,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
+): String {
customLabel?.takeIf { it.isNotBlank() }?.let { return it }
+ if (vendor == HwWalletVendor.BLOCKSTREAM) return model?.takeIf { it.isNotBlank() } ?: "Jade"
label?.takeIf { it != model }?.let { return it }
val resolvedModel = model ?: return "Trezor"
return if (resolvedModel.startsWith("Trezor")) resolvedModel else "Trezor $resolvedModel"
}
private val KnownDevice.displayName: String
- get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel)
+ get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel, vendor = vendor)
+
+private fun TrezorFeatures.toHwConnectedDevice(deviceId: String) = HwConnectedDevice(
+ vendor = HwWalletVendor.TREZOR,
+ id = deviceId,
+ label = label,
+ model = model,
+ walletId = null,
+ passphraseProtection = passphraseProtection == true,
+ isLocked = pinProtection == true && unlocked == false,
+)
/** The device has passphrase protection turned off, so it cannot open a hidden wallet at all. */
class HwPassphraseDisabledError : AppError("Passphrase protection is off on this device")
@@ -1041,6 +1250,9 @@ class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet
class HwReceiveAddressMismatchError(message: String) : AppError(message)
+/** The device has no wallet yet; it has to be created or restored on the device itself. */
+class HwDeviceUninitializedError : AppError("Hardware device is not set up")
+
/**
* A removal asked to keep the wallet's backup data, but its tags could not be read. Raised before
* anything is deleted, so the wallet is untouched and the removal can be retried or repeated without
diff --git a/app/src/main/java/to/bitkit/repositories/JadeRepo.kt b/app/src/main/java/to/bitkit/repositories/JadeRepo.kt
new file mode 100644
index 0000000000..5407f2139b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/JadeRepo.kt
@@ -0,0 +1,858 @@
+package to.bitkit.repositories
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+import com.synonym.bitkitcore.AccountType
+import com.synonym.bitkitcore.CompletedTransaction
+import com.synonym.bitkitcore.JadeDeviceInfo
+import com.synonym.bitkitcore.JadeException
+import com.synonym.bitkitcore.JadeState
+import com.synonym.bitkitcore.JadeTransportKind
+import com.synonym.bitkitcore.JadeVersionInfo
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+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.async.appScope
+import to.bitkit.data.HwWalletStore
+import to.bitkit.data.PendingNameUpdate
+import to.bitkit.di.IoDispatcher
+import to.bitkit.env.Env
+import to.bitkit.ext.isJadeDeviceBusy
+import to.bitkit.ext.isJadeUserCancellation
+import to.bitkit.ext.nowMs
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.HwConnectedDevice
+import to.bitkit.models.HwDeviceState
+import to.bitkit.models.HwFundingAddressType
+import to.bitkit.models.HwNearbyDevice
+import to.bitkit.models.HwWalletVendor
+import to.bitkit.models.KnownDevice
+import to.bitkit.models.TransportType
+import to.bitkit.models.deriveHardwareWalletId
+import to.bitkit.models.findHardwareWalletId
+import to.bitkit.models.isReplacedBy
+import to.bitkit.models.matches
+import to.bitkit.models.toJadeNetwork
+import to.bitkit.models.walletKey
+import to.bitkit.models.withHardwareWalletIds
+import to.bitkit.services.JadeService
+import to.bitkit.services.JadeTransport
+import to.bitkit.utils.AppError
+import to.bitkit.utils.HwErrorPresenter
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.ExperimentalTime
+
+/**
+ * Device sessions for Blockstream Jade wallets. Owns discovery, connect and unlock, the persisted
+ * entries of paired Jades, and the two device operations Bitkit needs (address verification and
+ * PSBT signing). Watchers, compose and broadcast are vendor neutral and stay in [TrezorRepo].
+ *
+ * A Jade locks on every power cycle and unlocks with a PIN entered on the device, which needs the
+ * pinserver round trip that bitkit-core performs. Silent reconnects never unlock: only the user's own
+ * action (pairing, verifying an address, signing) puts the PIN screen on the device.
+ */
+@OptIn(ExperimentalTime::class)
+@Suppress("TooManyFunctions", "LargeClass")
+@Singleton
+class JadeRepo @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val jadeService: JadeService,
+ private val jadeTransport: JadeTransport,
+ private val hwWalletStore: HwWalletStore,
+ private val clock: Clock,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) {
+ companion object {
+ private const val TAG = "JadeRepo"
+ private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4
+ private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds
+ private val CONNECT_ATTEMPT_POLL_INTERVAL = 250.milliseconds
+ private val CONNECT_ATTEMPT_MAX_WAIT = 28.seconds
+
+ /**
+ * How long the app may sit in the background before an open Bluetooth link is released. A
+ * process killed with the link open leaves the Jade holding a dead connection and, as seen on
+ * hardware, drops the bond; closing the link cleanly first avoids that. The delay keeps a
+ * brief switch to another app during a PIN or signing prompt from cancelling it.
+ */
+ private val BACKGROUND_RELEASE_DELAY = 30.seconds
+ private val ALL_ACCOUNT_TYPES = listOf(
+ AccountType.LEGACY,
+ AccountType.WRAPPED_SEGWIT,
+ AccountType.NATIVE_SEGWIT,
+ AccountType.TAPROOT,
+ )
+ }
+
+ private val _state = MutableStateFlow(JadeRepoState())
+ val state = _state.asStateFlow()
+
+ private val scope = appScope(ioDispatcher, TAG)
+ private var isSetup = CompletableDeferred()
+ private val setupMutex = Mutex()
+
+ @Volatile
+ private var transportReconnectJob: Job? = null
+
+ @Volatile
+ private var backgroundReleaseJob: Job? = null
+
+ init {
+ observeExternalDisconnects()
+ observeTransportRestored()
+ }
+
+ suspend fun initialize(): Result = withContext(ioDispatcher) {
+ setupMutex.withLock {
+ if (isSetup.isCancelled) {
+ isSetup = CompletableDeferred()
+ }
+ if (isSetup.isCompleted) {
+ isSetup.await()
+ return@withLock Result.success(Unit)
+ }
+ val setup = isSetup
+ runSuspendCatching {
+ jadeService.initialize()
+ val known = loadKnownDevices()
+ _state.update { it.copy(knownDevices = known.toImmutableList(), error = null) }
+ setup.complete(Unit)
+ Unit
+ }.onFailure {
+ setup.completeExceptionally(it)
+ if (isSetup === setup) {
+ isSetup = CompletableDeferred()
+ }
+ Logger.error("Jade init failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ }
+ }
+
+ suspend fun resetState() = withContext(ioDispatcher) {
+ setupMutex.withLock {
+ isSetup.cancel()
+ isSetup = CompletableDeferred()
+ }
+ transportReconnectJob?.cancel()
+ transportReconnectJob = null
+ if (_state.value.connected != null) {
+ runSuspendCatching { disconnect().getOrThrow() }
+ }
+ _state.update { JadeRepoState() }
+ }
+
+ /**
+ * Discovers nearby Jades. Core refuses to scan while a session is open, since a Bluetooth scan
+ * drops the open link, so the devices of the last scan are reused instead of failing the search.
+ */
+ suspend fun scan(includeBluetooth: Boolean = true): Result> = withContext(ioDispatcher) {
+ runSuspendCatching {
+ awaitSetup()
+ _state.update { it.copy(isScanning = true, error = null) }
+ val devices = if (jadeService.isConnected()) {
+ jadeService.listDevices()
+ } else {
+ jadeService.scan(includeBluetooth = includeBluetooth)
+ }
+ val known = _state.value.knownDevices
+ val nearby = devices.filterNot { known.any { entry -> entry.isSameDevice(it) } }
+ _state.update { it.copy(isScanning = false, nearbyDevices = nearby.toImmutableList()) }
+ devices
+ }.onFailure {
+ Logger.error("Jade scan failed", it, context = TAG)
+ _state.update { s -> s.copy(isScanning = false, error = errorMessage(it)) }
+ }
+ }
+
+ /** Pairs a discovered device: connects, unlocks with the on-device PIN, reads its accounts and stores it. */
+ suspend fun connect(
+ path: String,
+ requestUsbPermission: Boolean = true,
+ ): Result = withContext(ioDispatcher) {
+ var startedConnecting = false
+ try {
+ runSuspendCatching {
+ awaitSetup()
+ startedConnecting = true
+ _state.update { it.copy(isConnecting = true, error = null) }
+ val device = resolveDevice(path)
+ val connected = connectDevice(device, requestUsbPermission = requestUsbPermission, unlock = true)
+ _state.update {
+ it.copy(nearbyDevices = it.nearbyDevices.filter { d -> d.path != path }.toImmutableList())
+ }
+ connected
+ }.onFailure {
+ Logger.error("Jade connect failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ } finally {
+ if (startedConnecting) {
+ _state.update { it.copy(isConnecting = false) }
+ }
+ }
+ }
+
+ /**
+ * Reconnects a paired Jade. Bluetooth entries reconnect by their stored address; a USB entry is
+ * found among the plugged-in Jades and confirmed by its hardware id, since its path changes on
+ * every replug. With [unlock] off the session stays locked, which is what silent reconnects want.
+ */
+ suspend fun connectKnownDevice(
+ deviceId: String,
+ forceSession: Boolean = false,
+ unlock: Boolean = true,
+ requestUsbPermission: Boolean = true,
+ ): Result = withContext(ioDispatcher) {
+ if (isConnectInProgress()) {
+ return@withContext Result.failure(AppError("Connection already in progress"))
+ }
+ connectKnownDeviceUnguarded(deviceId, forceSession, unlock, requestUsbPermission)
+ }
+
+ private suspend fun connectKnownDeviceUnguarded(
+ deviceId: String,
+ forceSession: Boolean,
+ unlock: Boolean,
+ requestUsbPermission: Boolean,
+ ): Result {
+ var startedConnecting = false
+ return try {
+ runSuspendCatching {
+ startedConnecting = true
+ _state.update { it.copy(isConnecting = true, error = null) }
+ awaitSetup()
+ if (forceSession) disconnectStaleSession(deviceId)
+ val entry = knownDevice(deviceId) ?: throw AppError("Unknown Jade '$deviceId'")
+ val device = findKnownDeviceNearby(entry, requestUsbPermission = requestUsbPermission)
+ val connected = connectDevice(
+ device = device,
+ requestUsbPermission = requestUsbPermission,
+ unlock = unlock,
+ expected = entry,
+ )
+ Logger.info("Reconnected known Jade '${entry.id}'", context = TAG)
+ connected
+ }.onFailure {
+ Logger.error("Jade reconnect failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ if (!forceSession) disconnectStaleSession(deviceId)
+ }
+ } finally {
+ if (startedConnecting) {
+ _state.update { it.copy(isConnecting = false) }
+ }
+ }
+ }
+
+ /** A live, unlocked session for [deviceId]: reuses the current one, else reconnects and unlocks. */
+ suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ awaitSetup()
+ val current = awaitConnectedOrNull(deviceId)
+ ?: return@runSuspendCatching connectKnownDevice(deviceId, forceSession = true).getOrThrow()
+ if (!current.isLocked) return@runSuspendCatching current
+ val version = unlockConnected()
+ current.copy(versionInfo = version).also { unlocked ->
+ _state.update { it.copy(connected = unlocked) }
+ }
+ }
+ }
+
+ /** Silent reconnect after a transport came back; never asks for the PIN. */
+ suspend fun autoReconnect(preferredTransport: TransportType? = null): Result =
+ withContext(ioDispatcher) {
+ if (isConnectInProgress()) {
+ return@withContext Result.failure(AppError("Connect already in progress"))
+ }
+ val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() }
+ if (knownDevices.isEmpty()) {
+ return@withContext Result.failure(AppError("No known devices"))
+ }
+ _state.update { it.copy(isAutoReconnecting = true, error = null) }
+ try {
+ runSuspendCatching {
+ awaitSetup()
+ _state.value.connected?.takeIf { jadeService.isConnected() }?.let { return@runSuspendCatching it }
+ if (jadeService.isConnected()) runSuspendCatching { jadeService.disconnect() }
+ val ordered = knownDevices.sortedByDescending { it.transportType == preferredTransport }
+ val entry = ordered.firstOrNull { it.transportType != TransportType.USB || hasPluggedInJade() }
+ ?: throw AppError("No known device found nearby")
+ connectKnownDeviceUnguarded(
+ deviceId = entry.id,
+ forceSession = false,
+ unlock = false,
+ requestUsbPermission = false,
+ ).getOrThrow()
+ }.onFailure {
+ Logger.error("Jade auto-reconnect failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ } finally {
+ _state.update { it.copy(isAutoReconnecting = false) }
+ }
+ }
+
+ suspend fun verifyAddress(
+ addressType: HwFundingAddressType,
+ derivationPath: String,
+ expectedAddress: String,
+ ): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ jadeService.verifyAddress(
+ network = Env.network.toJadeNetwork(),
+ variant = addressType.jadeVariant,
+ derivationPath = derivationPath,
+ expectedAddress = expectedAddress,
+ )
+ }.onFailure {
+ Logger.error("Jade verifyAddress failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ }
+
+ /** Signs on the device and completes the PSBT into a broadcastable transaction. */
+ suspend fun signPsbt(psbtBase64: String): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ val signed = jadeService.signPsbt(network = Env.network.toJadeNetwork(), psbtBase64 = psbtBase64)
+ jadeService.finalizePsbt(originalPsbt = psbtBase64, signedPsbt = signed)
+ }.onFailure {
+ Logger.error("Jade signPsbt failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ }
+
+ suspend fun getMasterFingerprint(): Result = withContext(ioDispatcher) {
+ runSuspendCatching { jadeService.getMasterFingerprint(Env.network.toJadeNetwork()) }
+ .onFailure { Logger.error("Jade getMasterFingerprint failed", it, context = TAG) }
+ }
+
+ suspend fun disconnect(): Result = withContext(ioDispatcher) {
+ val connected = _state.value.connected
+ runSuspendCatching {
+ try {
+ jadeService.disconnect()
+ } finally {
+ connected?.let { jadeTransport.disconnectDevice(it.path) }
+ }
+ Unit
+ }.also {
+ _state.update { it.copy(connected = null) }
+ }.onFailure {
+ Logger.error("Jade disconnect failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ }
+
+ suspend fun disconnectStaleSession(deviceId: String): Result = withContext(NonCancellable) {
+ withContext(ioDispatcher) {
+ val connected = _state.value.connected
+ if (connected != null && !connected.matches(deviceId)) {
+ return@withContext Result.success(Unit)
+ }
+ val result = runSuspendCatching {
+ try {
+ jadeService.disconnect()
+ } finally {
+ val path = connected?.path ?: knownDevice(deviceId)?.path ?: deviceId
+ jadeTransport.disconnectDevice(path)
+ }
+ Unit
+ }.onFailure {
+ Logger.warn("Failed to disconnect stale Jade session for '$deviceId'", it, context = TAG)
+ }
+ _state.update { it.copy(connected = null) }
+ result
+ }
+ }
+
+ /**
+ * Forgets a paired entry. [walletKey] scopes the removal to one wallet identity; the same Jade
+ * paired over both transports is stored once per transport and both entries go together.
+ */
+ suspend fun forgetDevice(
+ deviceId: String,
+ walletKey: String? = null,
+ pendingName: PendingNameUpdate? = null,
+ ): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ val stored = loadKnownDevices()
+ val storedEntries = stored.map { it.id to it.walletKey }.toSet()
+ val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries }
+ val isForgotten: (KnownDevice) -> Boolean = when (walletKey) {
+ null -> { entry -> entry.id == deviceId }
+ else -> { entry -> entry.walletKey == walletKey }
+ }
+ val updated = knownDevices.filterNot(isForgotten)
+ val connected = _state.value.connected
+ val forgetsSession = connected != null && knownDevices.filter(isForgotten).any { it.id == connected.id }
+ val disconnectResult = if (forgetsSession) {
+ disconnect()
+ } else {
+ Result.success(Unit)
+ }
+ saveKnownDevices(updated, pendingName)
+ _state.update { it.copy(knownDevices = updated.toImmutableList()) }
+ disconnectResult.onFailure {
+ Logger.warn("Ignored disconnect failure while forgetting Jade '$deviceId'", it, context = TAG)
+ }
+ Logger.info("Forgot Jade '$deviceId'", context = TAG)
+ }.onFailure {
+ Logger.error("Forget Jade failed", it, context = TAG)
+ _state.update { s -> s.copy(error = errorMessage(it)) }
+ }
+ }
+
+ suspend fun hasKnownDevice(deviceId: String): Boolean = withContext(ioDispatcher) {
+ knownDevice(deviceId) != null
+ }
+
+ /** Whether [deviceId] names a known USB entry or any USB Jade is paired: USB paths change on replug. */
+ suspend fun hasKnownUsbDevice(deviceId: String): Boolean = withContext(ioDispatcher) {
+ val known = knownDevices()
+ known.any { it.matches(deviceId) } || known.any { it.transportType == TransportType.USB }
+ }
+
+ suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = withContext(ioDispatcher) {
+ knownDevice(deviceId)?.transportType == TransportType.BLUETOOTH
+ }
+
+ fun deriveWalletId(xpubs: Map): String? =
+ deriveHardwareWalletId(xpubs, HwWalletVendor.BLOCKSTREAM)?.takeIf { it.isNotBlank() }
+
+ fun onTransportRestored(transportType: TransportType) = launchTransportReconnect(transportType)
+
+ /** Releases an open Bluetooth link after [BACKGROUND_RELEASE_DELAY] unless the app comes back first. */
+ fun onAppBackgrounded() {
+ if (backgroundReleaseJob?.isActive == true) return
+ backgroundReleaseJob = scope.launch {
+ delay(BACKGROUND_RELEASE_DELAY)
+ val connected = _state.value.connected ?: return@launch
+ if (connected.transport != JadeTransportKind.BLUETOOTH) return@launch
+ Logger.info("Releasing the Jade bluetooth link while the app is in the background", context = TAG)
+ disconnect()
+ }
+ }
+
+ fun onAppForegrounded() {
+ backgroundReleaseJob?.cancel()
+ backgroundReleaseJob = null
+ scope.launch {
+ if (_state.value.connected != null || isConnectInProgress()) return@launch
+ val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() }
+ if (knownDevices.none { it.transportType == TransportType.BLUETOOTH }) return@launch
+ Logger.info("Attempting Jade bluetooth auto-reconnect after app foregrounded", context = TAG)
+ launchTransportReconnect(TransportType.BLUETOOTH)
+ }
+ }
+
+ /** Pre-connects a known Bluetooth Jade before a sign screen asks for it, without unlocking. */
+ fun warmUpKnownDevice(deviceId: String) {
+ scope.launch {
+ if (awaitConnectedOrNull(deviceId) != null) return@launch
+ if (isConnectInProgress()) return@launch
+ if (!isKnownBluetoothDevice(deviceId)) return@launch
+ Logger.info("Warming up known Jade '$deviceId'", context = TAG)
+ connectKnownDevice(deviceId, unlock = false).onFailure {
+ Logger.debug("Warm up connect failed for '$deviceId'", context = TAG)
+ }
+ }
+ }
+
+ fun clearError() = _state.update { it.copy(error = null) }
+
+ // ------------------------------------------------------------------
+ // Connect internals
+ // ------------------------------------------------------------------
+
+ private suspend fun resolveDevice(path: String): JadeDeviceInfo {
+ _state.value.nearbyDevices.firstOrNull { it.path == path }?.let { return it }
+ jadeService.listDevices().firstOrNull { it.path == path }?.let { return it }
+ // Core only connects to a device of its last scan, so refresh it for a path handed in by
+ // the OS attach intent.
+ val includeBluetooth = path.startsWith(BLE_PATH_PREFIX)
+ val scanned = if (jadeService.isConnected()) jadeService.listDevices() else jadeService.scan(includeBluetooth)
+ return scanned.firstOrNull { it.path == path }
+ ?: JadeDeviceInfo(
+ path = path,
+ transport = if (includeBluetooth) JadeTransportKind.BLUETOOTH else JadeTransportKind.SERIAL,
+ name = null,
+ serialNumber = null,
+ )
+ }
+
+ private suspend fun findKnownDeviceNearby(entry: KnownDevice, requestUsbPermission: Boolean): JadeDeviceInfo {
+ val transport = entry.transportType.toJadeTransportKind()
+ val includeBluetooth = transport == JadeTransportKind.BLUETOOTH
+ val scanned = runSuspendCatching {
+ if (jadeService.isConnected()) jadeService.listDevices() else jadeService.scan(includeBluetooth)
+ }.getOrElse {
+ Logger.warn("Scan before Jade reconnect failed", it, context = TAG)
+ emptyList()
+ }
+ scanned.firstOrNull { it.path == entry.path && it.transport == transport }?.let { return it }
+ if (includeBluetooth) {
+ // A Jade advertises under a fresh random address after a reboot, but its name carries
+ // the tail of the efuse MAC, so a renamed entry is still recognisable in the scan.
+ scanned.firstOrNull { it.transport == transport && entry.advertisesAs(it.name) }?.let { return it }
+ // Otherwise the stored address is tried directly: the transport resolves it without a scan hit.
+ return JadeDeviceInfo(path = entry.path, transport = transport, name = entry.name, serialNumber = null)
+ }
+ val candidates = scanned.filter { it.transport == JadeTransportKind.SERIAL }
+ .filter { requestUsbPermission || jadeTransport.hasUsbPermission(it.path) }
+ return candidates.firstOrNull() ?: throw AppError("Jade not found nearby: is it plugged in?")
+ }
+
+ private suspend fun connectDevice(
+ device: JadeDeviceInfo,
+ requestUsbPermission: Boolean,
+ unlock: Boolean,
+ expected: KnownDevice? = null,
+ ): ConnectedJadeDevice {
+ var version = jadeService.connect(device.transport, device.path, requestUsbPermission = requestUsbPermission)
+ Logger.info(
+ "Connected Jade '${device.path}' firmware '${version.jadeVersion}' state '${version.jadeState}'",
+ context = TAG,
+ )
+ rejectUnusableDevice(version, expected)
+ if (unlock && version.jadeState == JadeState.LOCKED) {
+ version = unlockConnected()
+ }
+ val known = if (version.jadeState.isUnlocked()) {
+ val xpubs = exportAccounts()
+ addOrUpdateKnownDevice(device, version, xpubs)
+ } else {
+ // Still locked, so its keys cannot be read: only an entry already holding them is usable.
+ val entry = expected ?: knownDevice(deviceIdFor(device.transport, version.efuseMac) ?: device.path)
+ entry?.let { refreshKnownDevice(it, device) } ?: rejectDevice(JadeException.DeviceLocked())
+ }
+ val connected = ConnectedJadeDevice(
+ id = known.id,
+ path = device.path,
+ transport = device.transport,
+ versionInfo = version,
+ walletId = known.walletId.takeIf { it.isNotBlank() },
+ )
+ _state.update { it.copy(connected = connected) }
+ return connected
+ }
+
+ private suspend fun rejectUnusableDevice(version: JadeVersionInfo, expected: KnownDevice?) {
+ if (version.jadeState == JadeState.UNINIT) rejectDevice(HwDeviceUninitializedError())
+ val expectedHardwareId = expected?.jadeDeviceId
+ val hardwareId = version.efuseMac
+ if (expectedHardwareId != null && hardwareId != null && expectedHardwareId != hardwareId) {
+ rejectDevice(AppError("A different Jade is connected"))
+ }
+ }
+
+ private suspend fun rejectDevice(error: Throwable): Nothing {
+ runSuspendCatching { jadeService.disconnect() }
+ throw error
+ }
+
+ private suspend fun unlockConnected(): JadeVersionInfo {
+ _state.update { it.copy(isUnlocking = true) }
+ try {
+ // Core enforces the five minute unlock deadline; the PIN is typed on the device.
+ jadeService.unlock(Env.network.toJadeNetwork())
+ return jadeService.refreshVersionInfo()
+ } finally {
+ _state.update { it.copy(isUnlocking = false) }
+ }
+ }
+
+ private suspend fun exportAccounts(): Map {
+ val network = Env.network.toJadeNetwork()
+ val export = runSuspendCatching { jadeService.getAccountExport(network, ALL_ACCOUNT_TYPES) }
+ .getOrElse {
+ if (it !is JadeException.UnsupportedFirmware) throw it
+ Logger.warn("Retrying Jade account export without taproot", it, context = TAG)
+ jadeService.getAccountExport(network, ALL_ACCOUNT_TYPES - AccountType.TAPROOT)
+ }
+ val xpubs = export.accounts.associate {
+ HwFundingAddressType.fromJadeVariant(it.variant).settingsKey to it.xpub
+ }
+ if (xpubs.isEmpty()) throw AppError("Could not read any account keys from your Jade. Reconnect and try again.")
+ return xpubs
+ }
+
+ private suspend fun addOrUpdateKnownDevice(
+ device: JadeDeviceInfo,
+ version: JadeVersionInfo,
+ fetchedXpubs: Map,
+ ): KnownDevice {
+ val stored = loadKnownDevices()
+ val storedEntries = stored.map { it.id to it.walletKey }.toSet()
+ val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries }
+ val id = deviceIdFor(device.transport, version.efuseMac) ?: device.path
+ // The hardware id, not the path, identifies an entry: a USB path is renumbered on every plug.
+ val candidates = knownDevices.filter { it.id == id }
+ val previous = candidates.firstOrNull {
+ it.xpubs.values.intersect(fetchedXpubs.values.toSet()).isNotEmpty()
+ } ?: candidates.singleOrNull()?.takeIf { it.xpubs.isEmpty() }
+ val xpubs = previous?.xpubs.orEmpty() + fetchedXpubs
+ val identityKey = walletKey(xpubs, id)
+ val named = previous ?: knownDevices.firstOrNull { it.walletKey == identityKey }
+ val resolvedWalletId = previous?.walletId?.takeIf { it.isNotBlank() }
+ ?: knownDevices.findHardwareWalletId(xpubs, fallback = id, vendor = HwWalletVendor.BLOCKSTREAM)
+ val pendingName = pendingNameFor(resolvedWalletId)
+ val known = KnownDevice(
+ id = id,
+ name = device.name,
+ path = device.path,
+ transportType = device.transport.toTransportType(),
+ label = null,
+ model = version.boardType.toJadeModel(),
+ lastConnectedAt = clock.nowMs(),
+ xpubs = xpubs,
+ customLabel = named?.customLabel ?: pendingName,
+ walletId = resolvedWalletId,
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ jadeDeviceId = version.efuseMac,
+ )
+ val updated = knownDevices.filterNot { it.isReplacedBy(known, refreshed = previous) } + known
+ saveKnownDevices(
+ updated,
+ pendingName = pendingName?.let { PendingNameUpdate(resolvedWalletId, name = null) },
+ )
+ _state.update { it.copy(knownDevices = updated.toImmutableList()) }
+ return known
+ }
+
+ private suspend fun refreshKnownDevice(entry: KnownDevice, device: JadeDeviceInfo): KnownDevice {
+ val refreshed = entry.copy(path = device.path, lastConnectedAt = clock.nowMs())
+ val updated = knownDevices().map { if (it.id == entry.id && it.walletKey == entry.walletKey) refreshed else it }
+ saveKnownDevices(updated)
+ _state.update { it.copy(knownDevices = updated.toImmutableList()) }
+ return refreshed
+ }
+
+ private suspend fun pendingNameFor(walletId: String): String? = walletId
+ .takeIf { it.isNotBlank() }
+ ?.let { runSuspendCatching { hwWalletStore.loadPendingNames()[it] }.getOrNull() }
+ ?.takeIf { it.isNotBlank() }
+
+ private suspend fun hasPluggedInJade(): Boolean = runSuspendCatching {
+ jadeService.scan(includeBluetooth = false).any { it.transport == JadeTransportKind.SERIAL }
+ }.getOrDefault(false)
+
+ private suspend fun knownDevices(): List =
+ (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id to it.walletKey }
+
+ private suspend fun knownDevice(deviceId: String): KnownDevice? =
+ knownDevices().firstOrNull { it.matches(deviceId) }
+
+ private suspend fun awaitConnectedOrNull(deviceId: String): ConnectedJadeDevice? {
+ connectedDevice(deviceId)?.let { return it }
+ if (isConnectInProgress()) {
+ transportReconnectJob?.takeIf { it.isActive }?.join()
+ waitForConnectAttempt(deviceId)
+ connectedDevice(deviceId)?.let { return it }
+ }
+ return null
+ }
+
+ private suspend fun connectedDevice(deviceId: String): ConnectedJadeDevice? {
+ val current = _state.value.connected ?: return null
+ return if (current.matches(deviceId) && jadeService.isConnected()) current else null
+ }
+
+ private suspend fun waitForConnectAttempt(deviceId: String) {
+ runCatching {
+ withTimeout(CONNECT_ATTEMPT_MAX_WAIT) {
+ while (true) {
+ if (connectedDevice(deviceId) != null) return@withTimeout
+ if (!isConnectInProgress()) return@withTimeout
+ delay(CONNECT_ATTEMPT_POLL_INTERVAL)
+ }
+ }
+ }.onFailure {
+ if (it is CancellationException && it !is TimeoutCancellationException) throw it
+ }
+ }
+
+ private fun isConnectInProgress(): Boolean = _state.value.let { it.isConnecting || it.isAutoReconnecting }
+
+ private fun launchTransportReconnect(transportType: TransportType) {
+ if (transportReconnectJob?.isActive == true) return
+ transportReconnectJob = scope.launch { retryAutoReconnect(transportType) }
+ }
+
+ private suspend fun retryAutoReconnect(transportType: TransportType) {
+ repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt ->
+ if (_state.value.connected != null || isConnectInProgress()) return
+ delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1))
+ if (_state.value.connected != null || isConnectInProgress()) return
+ Logger.info(
+ "Attempting Jade auto-reconnect after transport restored, attempt '${attempt + 1}'",
+ context = TAG
+ )
+ val result = autoReconnect(preferredTransport = transportType)
+ if (result.isSuccess) return
+ if (result.exceptionOrNull()?.isJadeDeviceBusy() == true) return
+ }
+ }
+
+ private fun observeExternalDisconnects() {
+ jadeTransport.externalDisconnect.onEach { path ->
+ val connected = _state.value.connected ?: return@onEach
+ if (connected.path != path) return@onEach
+ Logger.warn("External disconnect detected for Jade '${connected.id}'", context = TAG)
+ _state.update { it.copy(connected = null, error = "Device disconnected") }
+ runSuspendCatching { jadeService.notifyDisconnected(path) }
+ .onFailure { Logger.warn("Failed to report Jade disconnect", it, context = TAG) }
+ }.launchIn(scope)
+ }
+
+ private fun observeTransportRestored() {
+ jadeTransport.transportRestored.onEach { launchTransportReconnect(it) }.launchIn(scope)
+ }
+
+ private suspend fun awaitSetup() {
+ initialize().getOrThrow()
+ isSetup.await()
+ }
+
+ private suspend fun loadKnownDevices(): List = runCatching {
+ val devices = hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM)
+ val migrated = devices.withHardwareWalletIds()
+ if (migrated != devices) {
+ hwWalletStore.saveKnownDevices(migrated, vendor = HwWalletVendor.BLOCKSTREAM)
+ }
+ migrated
+ }.onFailure {
+ Logger.error("Failed to load known Jade devices", it, context = TAG)
+ }.getOrDefault(emptyList())
+
+ private suspend fun saveKnownDevices(devices: List, pendingName: PendingNameUpdate? = null) {
+ runSuspendCatching {
+ hwWalletStore.saveKnownDevices(devices, pendingName, vendor = HwWalletVendor.BLOCKSTREAM)
+ }.onFailure { Logger.error("Failed to save known Jade devices", it, context = TAG) }
+ }
+
+ private fun errorMessage(error: Throwable): String? = when {
+ error.isJadeUserCancellation() -> null
+ else -> HwErrorPresenter.userMessage(context, error, fallback = error.message.orEmpty()).ifBlank { null }
+ }
+
+ private fun KnownDevice.advertisesAs(name: String?): Boolean {
+ val suffix = jadeDeviceId?.takeLast(BLE_NAME_SUFFIX_LENGTH)?.takeIf { it.isNotBlank() } ?: return false
+ return name?.endsWith(suffix, ignoreCase = true) == true
+ }
+
+ private fun KnownDevice.isSameDevice(device: JadeDeviceInfo): Boolean = when (device.transport) {
+ JadeTransportKind.BLUETOOTH -> path == device.path || advertisesAs(device.name)
+ // A plugged-in Jade cannot be told from a paired one before connecting, so a paired USB Jade
+ // claims every serial device; a second one is added through the Add button, which offers it anyway.
+ JadeTransportKind.SERIAL -> transportType == TransportType.USB
+ }
+}
+
+@Stable
+data class JadeRepoState(
+ val isScanning: Boolean = false,
+ val isConnecting: Boolean = false,
+ val isAutoReconnecting: Boolean = false,
+ val isUnlocking: Boolean = false,
+ val knownDevices: ImmutableList = persistentListOf(),
+ val nearbyDevices: ImmutableList = persistentListOf(),
+ val connected: ConnectedJadeDevice? = null,
+ val error: String? = null,
+) {
+ fun connectedDeviceId(): String? = connected?.id
+
+ fun connectedWalletId(): String? = connected?.walletId
+}
+
+@Stable
+data class ConnectedJadeDevice(
+ val id: String,
+ val path: String,
+ val transport: JadeTransportKind,
+ val versionInfo: JadeVersionInfo,
+ val walletId: String? = null,
+) {
+ val isLocked: Boolean
+ get() = versionInfo.jadeState == JadeState.LOCKED
+
+ fun matches(deviceId: String): Boolean = id == deviceId || path == deviceId
+}
+
+fun JadeRepoState.toHwDeviceState() = HwDeviceState(
+ isScanning = isScanning,
+ isConnecting = isConnecting,
+ isAutoReconnecting = isAutoReconnecting,
+ isUnlocking = isUnlocking,
+ knownDevices = knownDevices,
+ nearbyDevices = nearbyDevices.map { it.toHwNearbyDevice() }.toImmutableList(),
+ connected = connected?.toHwConnectedDevice(),
+ error = error,
+)
+
+fun JadeDeviceInfo.toHwNearbyDevice() = HwNearbyDevice(
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ id = path,
+ path = path,
+ transportType = transport.toTransportType(),
+ name = name,
+ model = "Jade",
+)
+
+fun ConnectedJadeDevice.toHwConnectedDevice() = HwConnectedDevice(
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ id = id,
+ label = null,
+ model = versionInfo.boardType.toJadeModel(),
+ walletId = walletId,
+ passphraseProtection = false,
+ isLocked = isLocked,
+)
+
+private const val BLE_PATH_PREFIX = "ble:"
+
+/** A Jade advertises as "Jade" followed by the last six hex digits of its efuse MAC. */
+private const val BLE_NAME_SUFFIX_LENGTH = 6
+
+/** Stable entry id from the hardware id, so a USB replug refreshes the entry instead of adding one. */
+private fun deviceIdFor(transport: JadeTransportKind, efuseMac: String?): String? =
+ efuseMac?.takeIf { it.isNotBlank() }?.let { "jade:${transport.name.lowercase()}:$it" }
+
+private fun JadeState.isUnlocked(): Boolean = this == JadeState.READY || this == JadeState.TEMP
+
+/** Jade Plus reports a v2 board; every other board is the original Jade. */
+private fun String?.toJadeModel(): String =
+ if (this?.uppercase()?.contains("V2") == true) "Jade Plus" else "Jade"
+
+fun JadeTransportKind.toTransportType(): TransportType = when (this) {
+ JadeTransportKind.BLUETOOTH -> TransportType.BLUETOOTH
+ JadeTransportKind.SERIAL -> TransportType.USB
+}
+
+fun TransportType.toJadeTransportKind(): JadeTransportKind = when (this) {
+ TransportType.BLUETOOTH -> JadeTransportKind.BLUETOOTH
+ TransportType.USB -> JadeTransportKind.SERIAL
+}
diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
index 69a4bb9775..431b046303 100644
--- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
@@ -64,13 +64,22 @@ 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.HwConnectedDevice
+import to.bitkit.models.HwDeviceState
+import to.bitkit.models.HwNearbyDevice
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.KnownDevice
import to.bitkit.models.TransportType
+import to.bitkit.models.deriveHardwareWalletId
+import to.bitkit.models.findHardwareWalletId
+import to.bitkit.models.isReplacedBy
+import to.bitkit.models.matches
import to.bitkit.models.toAccountDerivationPath
import to.bitkit.models.toCoreNetwork
import to.bitkit.models.toSettingsString
import to.bitkit.models.toTrezorCoinType
+import to.bitkit.models.walletKey
+import to.bitkit.models.withHardwareWalletIds
import to.bitkit.services.TrezorDebugLog
import to.bitkit.services.TrezorService
import to.bitkit.services.TrezorTransport
@@ -202,7 +211,7 @@ class TrezorRepo @Inject constructor(
transportReconnectJob?.cancel()
transportReconnectJob = null
- val knownDevices = (_state.value.knownDevices + hwWalletStore.loadKnownDevices())
+ val knownDevices = (_state.value.knownDevices + hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR))
.distinctBy { it.id }
if (_state.value.connected != null) {
@@ -531,6 +540,7 @@ class TrezorRepo @Inject constructor(
network: BitkitCoreNetwork,
accountType: AccountType?,
coinSelection: CoinSelection,
+ fingerprint: String? = null,
): Result> = withContext(ioDispatcher) {
runSuspendCatching {
awaitSetup()
@@ -541,7 +551,7 @@ class TrezorRepo @Inject constructor(
network = network,
accountType = accountType,
coinSelection = coinSelection,
- fingerprint = null,
+ fingerprint = fingerprint,
)
}.onFailure {
Logger.error("Trezor offline composeTransaction failed", it, context = TAG)
@@ -872,7 +882,7 @@ class TrezorRepo @Inject constructor(
}
fun deriveWalletId(xpubs: Map): String? =
- deriveHardwareWalletId(xpubs)?.takeIf { it.isNotBlank() }
+ deriveHardwareWalletId(xpubs, HwWalletVendor.TREZOR)?.takeIf { it.isNotBlank() }
private suspend fun connectedFeatures(deviceId: String): TrezorFeatures? {
val current = _state.value.connected
@@ -1160,7 +1170,7 @@ class TrezorRepo @Inject constructor(
}
private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures): KnownDevice {
- val stored = hwWalletStore.loadKnownDevices()
+ val stored = hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)
val storedEntries = stored.map { it.id to it.walletKey }.toSet()
val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries }
val fetchResult = fetchAccountXpubs()
@@ -1193,7 +1203,7 @@ class TrezorRepo @Inject constructor(
val identityKey = walletKey(xpubs, deviceInfo.id)
val named = previous ?: knownDevices.firstOrNull { it.walletKey == identityKey }
val resolvedWalletId = previous?.walletId?.takeIf { it.isNotBlank() }
- ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id)
+ ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id, vendor = HwWalletVendor.TREZOR)
val pendingName = pendingNameFor(resolvedWalletId)
val customLabel = named?.customLabel ?: pendingName
val known = KnownDevice(
@@ -1296,10 +1306,10 @@ class TrezorRepo @Inject constructor(
}
private suspend fun loadKnownDevices(): List = runCatching {
- val devices = hwWalletStore.loadKnownDevices()
+ val devices = hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)
val migrated = devices.withHardwareWalletIds()
if (migrated != devices) {
- hwWalletStore.saveKnownDevices(migrated)
+ hwWalletStore.saveKnownDevices(migrated, vendor = HwWalletVendor.TREZOR)
}
migrated
}.onFailure {
@@ -1308,7 +1318,7 @@ class TrezorRepo @Inject constructor(
private suspend fun saveKnownDevices(devices: List, pendingName: PendingNameUpdate? = null) {
runSuspendCatching {
- hwWalletStore.saveKnownDevices(devices, pendingName)
+ hwWalletStore.saveKnownDevices(devices, pendingName, vendor = HwWalletVendor.TREZOR)
}.onFailure { Logger.error("Failed to save known devices", it, context = TAG) }
}
@@ -1531,54 +1541,34 @@ data class ConnectedTrezorDevice(
val walletId: String? = null,
)
-private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId
-
-/**
- * Whether a stored entry gives way to the one just read. That covers the identity it holds and the
- * entry this connect refreshed, since reading a previously rejected address type changes the
- * walletKey and matching on the new key alone would leave the old entry behind as a duplicate.
- * Wallets of a seed the device no longer carries go too: nothing would ever supersede them by key
- * material. An unknown device id proves nothing, so those entries are left alone.
- */
-private fun KnownDevice.isReplacedBy(known: KnownDevice, refreshed: KnownDevice?): Boolean {
- if (id != known.id) return false
- if (walletKey == known.walletKey) return true
- if (refreshed != null && walletKey == refreshed.walletKey) return true
- return known.trezorDeviceId != null && trezorDeviceId != null && trezorDeviceId != known.trezorDeviceId
-}
-
-private val KnownDevice.walletKey: String
- get() = walletKey(xpubs, id)
-
-private fun walletKey(xpubs: Map, fallback: String): String =
- xpubs.values.sorted().joinToString().ifEmpty { fallback }
-
-private fun deriveHardwareWalletId(xpubs: Map): String? =
- if (xpubs.isEmpty()) {
- null
- } else {
- runCatching { HwWalletId.derive(xpubs) }.getOrNull()
- }
-
-private fun List.findHardwareWalletId(xpubs: Map, fallback: String): String {
- val walletKey = walletKey(xpubs, fallback)
- return firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }
- ?: deriveHardwareWalletId(xpubs).orEmpty()
-}
+fun TrezorState.toHwDeviceState() = HwDeviceState(
+ isScanning = isScanning,
+ isConnecting = isConnecting,
+ isAutoReconnecting = isAutoReconnecting,
+ knownDevices = knownDevices,
+ nearbyDevices = nearbyDevices.map { it.toHwNearbyDevice() }.toImmutableList(),
+ connected = connected?.toHwConnectedDevice(),
+ error = error,
+)
-private fun List.withHardwareWalletIds(): List {
- val existingByWallet = filter { it.walletId.isNotBlank() }
- .associate { it.walletKey to it.walletId }
- val generatedByWallet = mutableMapOf()
+fun TrezorDeviceInfo.toHwNearbyDevice() = HwNearbyDevice(
+ vendor = HwWalletVendor.TREZOR,
+ id = id,
+ path = path,
+ transportType = transportType.toTransportType(),
+ name = name,
+ model = model,
+)
- return map {
- val walletId = existingByWallet[it.walletKey]
- ?: generatedByWallet.getOrPut(it.walletKey) {
- deriveHardwareWalletId(it.xpubs).orEmpty()
- }
- if (it.walletId == walletId) it else it.copy(walletId = walletId)
- }
-}
+fun ConnectedTrezorDevice.toHwConnectedDevice() = HwConnectedDevice(
+ vendor = HwWalletVendor.TREZOR,
+ id = id,
+ label = features.label,
+ model = features.model,
+ walletId = walletId,
+ passphraseProtection = features.passphraseProtection == true,
+ isLocked = features.pinProtection == true && features.unlocked == false,
+)
private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo(
id = id,
diff --git a/app/src/main/java/to/bitkit/services/JadeService.kt b/app/src/main/java/to/bitkit/services/JadeService.kt
new file mode 100644
index 0000000000..ea60c7396e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/JadeService.kt
@@ -0,0 +1,139 @@
+package to.bitkit.services
+
+import com.synonym.bitkitcore.AccountType
+import com.synonym.bitkitcore.CompletedTransaction
+import com.synonym.bitkitcore.JadeAccountExport
+import com.synonym.bitkitcore.JadeAddressVariant
+import com.synonym.bitkitcore.JadeDeviceInfo
+import com.synonym.bitkitcore.JadeNetwork
+import com.synonym.bitkitcore.JadePingStatus
+import com.synonym.bitkitcore.JadeTransportKind
+import com.synonym.bitkitcore.JadeVersionInfo
+import com.synonym.bitkitcore.jadeCancel
+import com.synonym.bitkitcore.jadeConnect
+import com.synonym.bitkitcore.jadeDisconnect
+import com.synonym.bitkitcore.jadeGetAccountExport
+import com.synonym.bitkitcore.jadeGetConnectedDevice
+import com.synonym.bitkitcore.jadeGetMasterFingerprint
+import com.synonym.bitkitcore.jadeGetVersionInfo
+import com.synonym.bitkitcore.jadeIsConnected
+import com.synonym.bitkitcore.jadeListDevices
+import com.synonym.bitkitcore.jadeNotifyDisconnected
+import com.synonym.bitkitcore.jadePing
+import com.synonym.bitkitcore.jadeRefreshVersionInfo
+import com.synonym.bitkitcore.jadeScan
+import com.synonym.bitkitcore.jadeSetTransportCallback
+import com.synonym.bitkitcore.jadeSignPsbt
+import com.synonym.bitkitcore.jadeUnlock
+import com.synonym.bitkitcore.jadeVerifyAddress
+import to.bitkit.async.ServiceQueue
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+import javax.inject.Singleton
+import com.synonym.bitkitcore.finalizePsbt as coreFinalizePsbt
+
+/**
+ * Thin wrapper over bitkit-core's `jade*` functions. Every call runs on [ServiceQueue.CORE], whose
+ * single thread also serialises the vendors' blocking Bluetooth scans against each other.
+ */
+@Suppress("TooManyFunctions")
+@Singleton
+class JadeService @Inject constructor(
+ private val transport: JadeTransport,
+) {
+ companion object {
+ private const val TAG = "JadeService"
+
+ /** Matches the Trezor transport's scan window so one search loop iteration stays predictable. */
+ const val SCAN_TIMEOUT_MS = 3_000u
+ }
+
+ @Volatile
+ private var callbackRegistered = false
+
+ private fun ensureCallbackRegistered() {
+ if (!callbackRegistered) {
+ synchronized(this) {
+ if (!callbackRegistered) {
+ val replaced = jadeSetTransportCallback(transport)
+ if (replaced) Logger.warn("Replaced a previously registered Jade transport", context = TAG)
+ callbackRegistered = true
+ }
+ }
+ }
+ }
+
+ suspend fun initialize() {
+ ServiceQueue.CORE.background { ensureCallbackRegistered() }
+ }
+
+ suspend fun scan(includeBluetooth: Boolean = true, timeoutMs: UInt = SCAN_TIMEOUT_MS): List =
+ ServiceQueue.CORE.background {
+ ensureCallbackRegistered()
+ transport.withBluetoothScanningEnabled(includeBluetooth) {
+ jadeScan(timeoutMs)
+ }
+ }
+
+ suspend fun listDevices(): List = ServiceQueue.CORE.background { jadeListDevices() }
+
+ suspend fun connect(
+ transportKind: JadeTransportKind,
+ path: String,
+ requestUsbPermission: Boolean = true,
+ ): JadeVersionInfo = ServiceQueue.CORE.background {
+ ensureCallbackRegistered()
+ transport.withUsbPermissionRequestsEnabled(requestUsbPermission) {
+ jadeConnect(transport = transportKind, path = path)
+ }
+ }
+
+ suspend fun disconnect() = ServiceQueue.CORE.background { jadeDisconnect() }
+
+ suspend fun cancel() = ServiceQueue.CORE.background { jadeCancel() }
+
+ suspend fun notifyDisconnected(path: String) = ServiceQueue.CORE.background { jadeNotifyDisconnected(path) }
+
+ suspend fun isConnected(): Boolean = ServiceQueue.CORE.background { jadeIsConnected() }
+
+ suspend fun getConnectedDevice(): JadeDeviceInfo? = ServiceQueue.CORE.background { jadeGetConnectedDevice() }
+
+ suspend fun getVersionInfo(): JadeVersionInfo? = ServiceQueue.CORE.background { jadeGetVersionInfo() }
+
+ suspend fun refreshVersionInfo(): JadeVersionInfo = ServiceQueue.CORE.background { jadeRefreshVersionInfo() }
+
+ suspend fun ping(): JadePingStatus = ServiceQueue.CORE.background { jadePing() }
+
+ suspend fun unlock(network: JadeNetwork) = ServiceQueue.CORE.background { jadeUnlock(network) }
+
+ suspend fun getMasterFingerprint(network: JadeNetwork): String =
+ ServiceQueue.CORE.background { jadeGetMasterFingerprint(network) }
+
+ suspend fun getAccountExport(
+ network: JadeNetwork,
+ accountTypes: List,
+ accountIndex: UInt = 0u,
+ ): JadeAccountExport = ServiceQueue.CORE.background {
+ jadeGetAccountExport(network = network, accountIndex = accountIndex, accountTypes = accountTypes)
+ }
+
+ suspend fun verifyAddress(
+ network: JadeNetwork,
+ variant: JadeAddressVariant,
+ derivationPath: String,
+ expectedAddress: String,
+ ) = ServiceQueue.CORE.background {
+ jadeVerifyAddress(
+ network = network,
+ variant = variant,
+ derivationPath = derivationPath,
+ expectedAddress = expectedAddress,
+ )
+ }
+
+ suspend fun signPsbt(network: JadeNetwork, psbtBase64: String): String =
+ ServiceQueue.CORE.background { jadeSignPsbt(network = network, psbt = psbtBase64) }
+
+ suspend fun finalizePsbt(originalPsbt: String, signedPsbt: String): CompletedTransaction =
+ ServiceQueue.CORE.background { coreFinalizePsbt(originalPsbt = originalPsbt, signedPsbt = signedPsbt) }
+}
diff --git a/app/src/main/java/to/bitkit/services/JadeTransport.kt b/app/src/main/java/to/bitkit/services/JadeTransport.kt
new file mode 100644
index 0000000000..cd62a27030
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/JadeTransport.kt
@@ -0,0 +1,1004 @@
+package to.bitkit.services
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.bluetooth.BluetoothStatusCodes
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.hardware.usb.UsbConstants
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbEndpoint
+import android.hardware.usb.UsbInterface
+import android.hardware.usb.UsbManager
+import android.os.Build
+import android.os.ParcelUuid
+import com.synonym.bitkitcore.JadeNativeDevice
+import com.synonym.bitkitcore.JadeTransportCallback
+import com.synonym.bitkitcore.JadeTransportErrorCode
+import com.synonym.bitkitcore.JadeTransportKind
+import com.synonym.bitkitcore.JadeTransportReadResult
+import com.synonym.bitkitcore.JadeTransportResult
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import to.bitkit.ext.bluetoothManager
+import to.bitkit.ext.usbManager
+import to.bitkit.models.HwWalletVendor
+import to.bitkit.models.TransportType
+import to.bitkit.ui.utils.HwUsbId
+import to.bitkit.ui.utils.hwUsbId
+import to.bitkit.utils.Logger
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Byte pipe between bitkit-core's Jade protocol and the phone's radios. Rust owns the CBOR
+ * protocol, the pinserver exchange and every deadline; this class only moves bytes over USB
+ * serial (a CP210x bridge on Jade v1, native USB CDC on Jade Plus) and Bluetooth (Nordic UART
+ * Service). Every callback runs on a Rust blocking thread, so blocking here is expected.
+ */
+@Suppress("LargeClass", "TooManyFunctions")
+@Singleton
+class JadeTransport @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : JadeTransportCallback {
+
+ companion object {
+ private const val TAG = "JadeTransport"
+ private const val ACTION_USB_PERMISSION = "to.bitkit.JADE_USB_PERMISSION"
+ private const val BLE_PATH_PREFIX = "ble:"
+
+ /** jade-client-rs `MAX_CHUNK_BYTES`; serial has no MTU so the crate's own serial transport uses it too. */
+ const val MAX_CHUNK_SIZE = 509
+ private const val DEFAULT_ATT_MTU = 23
+ private const val ATT_HEADER_BYTES = 3
+ private const val REQUESTED_ATT_MTU = 517
+
+ private const val USB_PERMISSION_TIMEOUT_MS = 60_000L
+ private const val USB_CONTROL_TIMEOUT_MS = 1_000
+ private const val USB_WRITE_TIMEOUT_MS = 5_000
+ private const val USB_READ_TIMEOUT_MAX_MS = 1_000
+
+ /** Silicon Labs CP210x vendor requests (AN571); bmRequestType host-to-device, vendor, interface. */
+ private const val CP210X_REQUEST_TYPE_OUT = 0x41
+ private const val CP210X_IFC_ENABLE = 0x00
+ private const val CP210X_SET_LINE_CTL = 0x03
+ private const val CP210X_SET_MHS = 0x07
+ private const val CP210X_PURGE = 0x12
+ private const val CP210X_SET_BAUDRATE = 0x1E
+ private const val CP210X_UART_ENABLE = 0x0001
+
+ /** wValue bits 8-15 data bits (8), bits 4-7 parity (none), bits 0-3 stop bits (1). */
+ private const val CP210X_LINE_CTL_8N1 = 0x0800
+
+ /**
+ * wValue low byte holds the DTR (bit 0) and RTS (bit 1) states, the high byte which of the two
+ * the write applies to. Both lines always change together in one transfer: the ESP32 auto-program
+ * circuit only resets or boot-modes the chip while the two differ.
+ */
+ private const val CP210X_MHS_LINES_ON_OPEN = 0x0303
+ private const val CP210X_MHS_LINES_ON_CLOSE = 0x0300
+ private const val CP210X_PURGE_ALL = 0x000F
+
+ /** 115200 baud as a 32-bit little-endian value. */
+ private val CP210X_BAUDRATE_115200 = byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00)
+
+ /** USB CDC PSTN class requests; bmRequestType host-to-device, class, interface. */
+ private const val CDC_REQUEST_TYPE_OUT = 0x21
+ private const val CDC_SET_LINE_CODING = 0x20
+ private const val CDC_SET_CONTROL_LINE_STATE = 0x22
+
+ /** dwDTERate 115200 LE, bCharFormat 0 (one stop bit), bParityType 0 (none), bDataBits 8. */
+ private val CDC_LINE_CODING_115200_8N1 = byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00, 0x00, 0x00, 0x08)
+ private const val CDC_LINE_STATE_ON_OPEN = 0x0003
+ private const val CDC_LINE_STATE_ON_CLOSE = 0x0000
+
+ private val NUS_SERVICE_UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e")
+ private val NUS_WRITE_CHAR_UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e")
+ private val NUS_NOTIFY_CHAR_UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e")
+ private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ private const val BLE_SCAN_MIN_MS = 500L
+ private const val BLE_SCAN_MAX_MS = 15_000L
+ private const val BLE_CONNECTION_TIMEOUT_MS = 15_000L
+
+ /**
+ * A write to an encrypted characteristic waits while the phone (re)pairs with the Jade, which
+ * needs a passkey confirmation on the device and gives up after 30 s, so the budget covers that.
+ */
+ private const val BLE_WRITE_TIMEOUT_MS = 35_000L
+
+ private const val STALE_BOND_ERROR =
+ "Bluetooth pairing is no longer valid: forget the Jade in Bluetooth settings and pair it again"
+ private const val BLE_WRITE_BUSY_RETRY_DELAY_MS = 50L
+ private const val BLE_DISCONNECT_TIMEOUT_MS = 3_000L
+ private const val BOND_POLL_INTERVAL_MS = 500L
+
+ /** 60 s: the passkey has to be confirmed on the Jade and in the Android pairing dialog. */
+ private const val MAX_BOND_POLL_ATTEMPTS = 120
+
+ private fun ok() = JadeTransportResult(success = true, error = "", errorCode = null)
+
+ private fun fail(error: String, code: JadeTransportErrorCode?) =
+ JadeTransportResult(success = false, error = error, errorCode = code)
+
+ private fun readOk(data: ByteArray) =
+ JadeTransportReadResult(success = true, data = data, error = "", errorCode = null)
+
+ private fun readFail(error: String, code: JadeTransportErrorCode?) =
+ JadeTransportReadResult(success = false, data = byteArrayOf(), error = error, errorCode = code)
+
+ internal fun chunkSizeForMtu(mtu: Int): UInt = (mtu - ATT_HEADER_BYTES).coerceIn(1, MAX_CHUNK_SIZE).toUInt()
+ }
+
+ private val usbManager: UsbManager by lazy { context.usbManager }
+ private val bluetoothManager: BluetoothManager by lazy { context.bluetoothManager }
+ private val bluetoothAdapter: BluetoothAdapter? by lazy { bluetoothManager.adapter }
+
+ private val usbPermissionRequester by lazy {
+ UsbPermissionRequester(
+ context = context,
+ usbManager = usbManager,
+ action = ACTION_USB_PERMISSION,
+ timeoutMs = USB_PERMISSION_TIMEOUT_MS,
+ )
+ }
+
+ private val usbConnections = ConcurrentHashMap()
+ private val bleConnections = ConcurrentHashMap()
+ private val discoveredBleDevices = ConcurrentHashMap()
+ private val discoveredBleNames = ConcurrentHashMap()
+ private val userInitiatedCloseSet: MutableSet = ConcurrentHashMap.newKeySet()
+ private val optionScopeMutex = Mutex()
+
+ @Volatile
+ private var requestUsbPermissionEnabled = true
+
+ @Volatile
+ private var bluetoothScanningEnabled = true
+
+ private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /** Paths whose link dropped without the app asking for it: unplug, Bluetooth off, or a GATT drop. */
+ val externalDisconnect: SharedFlow = _externalDisconnect
+
+ private val _transportRestored = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /** Emits the transport that became available again: Bluetooth back on or a Jade plugged in. */
+ val transportRestored: SharedFlow = _transportRestored
+
+ private val connectionStateReceiver = ConnectionStateReceiver(
+ onBluetoothOff = {
+ bleConnections.forEach { (path, connection) ->
+ connection.isConnected = false
+ connection.writeStatus = BluetoothGatt.GATT_FAILURE
+ releasePendingBleOperations(
+ connectionLatch = connection.connectionLatch,
+ writeLatch = connection.writeLatch,
+ disconnectLatch = connection.disconnectLatch,
+ )
+ emitExternalDisconnect(path)
+ }
+ },
+ onBluetoothOn = { _transportRestored.tryEmit(TransportType.BLUETOOTH) },
+ onUsbDetached = { path ->
+ // Only flag it: a bulk transfer may be in flight on the Rust thread, and the fd is closed
+ // once Rust hands the disconnect back through closeDevice.
+ usbConnections[path]?.let {
+ it.detached = true
+ emitExternalDisconnect(path)
+ }
+ },
+ onUsbAttached = { device ->
+ if (isJadeUsbDevice(device)) _transportRestored.tryEmit(TransportType.USB)
+ },
+ )
+
+ init {
+ connectionStateReceiver.register(context)
+ }
+
+ private class UsbOpenDevice(
+ val connection: UsbDeviceConnection,
+ val driver: UsbDriverSelection,
+ /** One max-packet: a read that times out then never discards a partially received packet. */
+ val readBuffer: ByteArray = ByteArray(driver.readEndpoint.maxPacketSize),
+ /** Serialises transfers against close so the fd is never closed under an in-flight URB. */
+ val ioLock: Any = Any(),
+ @Volatile var detached: Boolean = false,
+ )
+
+ @Suppress("LongParameterList")
+ private class BleConnection(
+ val gatt: BluetoothGatt,
+ @Volatile var writeCharacteristic: BluetoothGattCharacteristic? = null,
+ /** Notifications in arrival order, untouched: frames are not aligned to notifications. */
+ val readQueue: LinkedBlockingQueue = LinkedBlockingQueue(),
+ @Volatile var mtu: Int = DEFAULT_ATT_MTU,
+ /** The link itself is up, whether or not the subscription that makes it usable succeeded. */
+ @Volatile var linkUp: Boolean = false,
+ @Volatile var isConnected: Boolean = false,
+ @Volatile var writesCompleted: Int = 0,
+ @Volatile var connectionLatch: CountDownLatch? = null,
+ @Volatile var writeLatch: CountDownLatch? = null,
+ @Volatile var disconnectLatch: CountDownLatch? = null,
+ @Volatile var writeStatus: Int = BluetoothGatt.GATT_SUCCESS,
+ )
+
+ suspend fun withUsbPermissionRequestsEnabled(
+ enabled: Boolean,
+ block: suspend () -> T,
+ ): T = optionScopeMutex.withLock {
+ val previous = requestUsbPermissionEnabled
+ requestUsbPermissionEnabled = enabled
+ try {
+ block()
+ } finally {
+ requestUsbPermissionEnabled = previous
+ }
+ }
+
+ suspend fun withBluetoothScanningEnabled(
+ enabled: Boolean,
+ block: suspend () -> T,
+ ): T = optionScopeMutex.withLock {
+ val previous = bluetoothScanningEnabled
+ bluetoothScanningEnabled = enabled
+ try {
+ block()
+ } finally {
+ bluetoothScanningEnabled = previous
+ }
+ }
+
+ fun isJadeUsbDevice(device: UsbDevice): Boolean = device.hwUsbId()?.vendor == HwWalletVendor.BLOCKSTREAM
+
+ fun hasUsbPermission(devicePath: String): Boolean {
+ val device = usbManager.deviceList[devicePath] ?: return false
+ return usbManager.hasPermission(device)
+ }
+
+ /** Whether the transport currently holds an open Bluetooth link; a scan would drop it. */
+ fun hasOpenBleConnection(): Boolean = bleConnections.values.any { it.isConnected }
+
+ /** App-initiated teardown; for Jade the same as [closeDevice], which releases the link fully. */
+ fun disconnectDevice(path: String): JadeTransportResult = closeDevice(path)
+
+ fun closeAllConnections() {
+ usbConnections.keys.toList().forEach { closeUsbDevice(it) }
+ bleConnections.keys.toList().forEach { disconnectBleDevice(it) }
+ }
+
+ // ------------------------------------------------------------------
+ // JadeTransportCallback
+ // ------------------------------------------------------------------
+
+ override fun scanDevices(timeoutMs: UInt): List {
+ val devices = mutableListOf()
+
+ runCatching { scanUsbDevices() }
+ .onSuccess {
+ devices.addAll(it)
+ Logger.debug("USB scan found '${it.size}' Jade device(s)", context = TAG)
+ }
+ .onFailure { Logger.error("USB scan failed", it, context = TAG) }
+
+ if (bluetoothScanningEnabled) {
+ runCatching { scanBleDevices(timeoutMs) }
+ .onSuccess {
+ devices.addAll(it)
+ Logger.debug("BLE scan found '${it.size}' Jade device(s)", context = TAG)
+ }
+ .onFailure { Logger.error("BLE scan failed", it, context = TAG) }
+ } else {
+ Logger.debug("Skipped BLE scan while Bluetooth scanning is disabled", context = TAG)
+ }
+
+ Logger.info("Found '${devices.size}' Jade device(s)", context = TAG)
+ return devices
+ }
+
+ override fun openDevice(path: String): JadeTransportResult =
+ if (isBlePath(path)) openBleDevice(path) else openUsbDevice(path)
+
+ override fun closeDevice(path: String): JadeTransportResult =
+ if (isBlePath(path)) disconnectBleDevice(path) else closeUsbDevice(path)
+
+ override fun writeChunk(path: String, data: ByteArray): JadeTransportResult =
+ if (isBlePath(path)) writeBleChunk(path, data) else writeUsbChunk(path, data)
+
+ override fun readChunk(path: String, timeoutMs: UInt): JadeTransportReadResult =
+ if (isBlePath(path)) readBleChunk(path, timeoutMs) else readUsbChunk(path, timeoutMs)
+
+ override fun getChunkSize(path: String): UInt = when {
+ isBlePath(path) -> chunkSizeForMtu(bleConnections[path]?.mtu ?: DEFAULT_ATT_MTU)
+ else -> MAX_CHUNK_SIZE.toUInt()
+ }
+
+ // ------------------------------------------------------------------
+ // USB serial
+ // ------------------------------------------------------------------
+
+ private fun scanUsbDevices(): List = usbManager.deviceList.values
+ .filter { isJadeUsbDevice(it) }
+ .map { device ->
+ JadeNativeDevice(
+ path = device.deviceName,
+ transport = JadeTransportKind.SERIAL,
+ name = runCatching { device.productName }.getOrNull(),
+ // Reading the serial number throws without permission on API 29+.
+ serialNumber = if (usbManager.hasPermission(device)) {
+ runCatching { device.serialNumber }.getOrNull()
+ } else {
+ null
+ },
+ )
+ }
+
+ @Suppress("TooGenericExceptionCaught", "ReturnCount")
+ private fun openUsbDevice(path: String): JadeTransportResult {
+ return try {
+ closeUsbDevice(path)
+
+ val device = usbManager.deviceList[path]
+ ?: return fail("Device not found: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+
+ if (!usbManager.hasPermission(device)) {
+ if (!requestUsbPermissionEnabled) {
+ Logger.info("Skipped USB permission request for '$path'", context = TAG)
+ return fail("USB permission missing for '$path'", JadeTransportErrorCode.PERMISSION_DENIED)
+ }
+ if (!usbPermissionRequester.request(device)) {
+ return fail("USB permission denied for '$path'", JadeTransportErrorCode.PERMISSION_DENIED)
+ }
+ }
+
+ val driver = selectUsbDriver(device)
+ ?: return fail("Unsupported USB device '$path'", null)
+
+ val connection = usbManager.openDevice(device)
+ ?: return fail("Failed to open device: '$path'", null)
+
+ val claimed = listOfNotNull(driver.controlInterface, driver.dataInterface)
+ .all { connection.claimInterface(it, true) }
+ if (!claimed) {
+ releaseUsb(connection, driver)
+ return fail("Failed to claim interface", null)
+ }
+
+ val initialised = when (driver.kind) {
+ UsbDriverKind.CP210X -> initCp210x(connection, driver)
+ UsbDriverKind.CDC_ACM -> initCdcAcm(connection, driver)
+ }
+ if (!initialised) {
+ releaseUsb(connection, driver)
+ return fail("Failed to configure serial link", null)
+ }
+
+ usbConnections[path] = UsbOpenDevice(connection = connection, driver = driver)
+ Logger.info("Opened USB device '$path' as '${driver.kind}'", context = TAG)
+ ok()
+ } catch (e: Exception) {
+ Logger.error("USB open failed", e, context = TAG)
+ fail(e.message ?: "Unknown error", null)
+ }
+ }
+
+ private fun initCp210x(connection: UsbDeviceConnection, driver: UsbDriverSelection): Boolean {
+ val index = driver.dataInterface.id
+ fun control(request: Int, value: Int, data: ByteArray? = null): Int = connection.controlTransfer(
+ CP210X_REQUEST_TYPE_OUT,
+ request,
+ value,
+ index,
+ data,
+ data?.size ?: 0,
+ USB_CONTROL_TIMEOUT_MS,
+ )
+ if (control(CP210X_IFC_ENABLE, CP210X_UART_ENABLE) < 0) {
+ Logger.error("CP210x interface enable failed", context = TAG)
+ return false
+ }
+ if (control(CP210X_SET_BAUDRATE, 0, CP210X_BAUDRATE_115200) < 0) {
+ Logger.error("CP210x baud rate setup failed", context = TAG)
+ return false
+ }
+ if (control(CP210X_SET_LINE_CTL, CP210X_LINE_CTL_8N1) < 0) {
+ Logger.error("CP210x line control setup failed", context = TAG)
+ return false
+ }
+ if (control(CP210X_SET_MHS, CP210X_MHS_LINES_ON_OPEN) < 0) {
+ Logger.warn("CP210x modem line setup failed", context = TAG)
+ }
+ if (control(CP210X_PURGE, CP210X_PURGE_ALL) < 0) {
+ Logger.warn("CP210x purge failed", context = TAG)
+ }
+ return true
+ }
+
+ private fun initCdcAcm(connection: UsbDeviceConnection, driver: UsbDriverSelection): Boolean {
+ val index = driver.controlInterface?.id ?: driver.dataInterface.id
+ val lineCoding = connection.controlTransfer(
+ CDC_REQUEST_TYPE_OUT,
+ CDC_SET_LINE_CODING,
+ 0,
+ index,
+ CDC_LINE_CODING_115200_8N1,
+ CDC_LINE_CODING_115200_8N1.size,
+ USB_CONTROL_TIMEOUT_MS,
+ )
+ if (lineCoding < 0) {
+ // Native USB ignores the baud rate; the request is only advisory.
+ Logger.warn("CDC line coding setup failed", context = TAG)
+ }
+ val lineState = connection.controlTransfer(
+ CDC_REQUEST_TYPE_OUT,
+ CDC_SET_CONTROL_LINE_STATE,
+ CDC_LINE_STATE_ON_OPEN,
+ index,
+ null,
+ 0,
+ USB_CONTROL_TIMEOUT_MS,
+ )
+ if (lineState < 0) {
+ Logger.warn("CDC control line setup failed", context = TAG)
+ }
+ return true
+ }
+
+ private fun setModemLinesOnClose(device: UsbOpenDevice) {
+ when (device.driver.kind) {
+ UsbDriverKind.CP210X -> device.connection.controlTransfer(
+ CP210X_REQUEST_TYPE_OUT,
+ CP210X_SET_MHS,
+ CP210X_MHS_LINES_ON_CLOSE,
+ device.driver.dataInterface.id,
+ null,
+ 0,
+ USB_CONTROL_TIMEOUT_MS,
+ )
+ UsbDriverKind.CDC_ACM -> device.connection.controlTransfer(
+ CDC_REQUEST_TYPE_OUT,
+ CDC_SET_CONTROL_LINE_STATE,
+ CDC_LINE_STATE_ON_CLOSE,
+ device.driver.controlInterface?.id ?: device.driver.dataInterface.id,
+ null,
+ 0,
+ USB_CONTROL_TIMEOUT_MS,
+ )
+ }
+ }
+
+ private fun releaseUsb(connection: UsbDeviceConnection, driver: UsbDriverSelection) {
+ driver.controlInterface?.let { connection.releaseInterface(it) }
+ connection.releaseInterface(driver.dataInterface)
+ connection.close()
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun closeUsbDevice(path: String): JadeTransportResult {
+ val device = usbConnections.remove(path) ?: return ok()
+ return try {
+ synchronized(device.ioLock) {
+ if (!device.detached) {
+ runCatching { setModemLinesOnClose(device) }
+ .onFailure { Logger.warn("Failed to clear modem lines for '$path'", it, context = TAG) }
+ }
+ releaseUsb(device.connection, device.driver)
+ }
+ Logger.info("Closed USB device '$path'", context = TAG)
+ ok()
+ } catch (e: Exception) {
+ Logger.error("USB close failed", e, context = TAG)
+ fail(e.message ?: "Unknown error", null)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun readUsbChunk(path: String, timeoutMs: UInt): JadeTransportReadResult {
+ val device = usbConnections[path]
+ ?: return readFail("Device not open: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+ if (device.detached) return readFail("USB device detached: '$path'", JadeTransportErrorCode.DISCONNECTED)
+
+ return try {
+ val timeout = timeoutMs.toLong().coerceIn(1L, USB_READ_TIMEOUT_MAX_MS.toLong()).toInt()
+ // Synchronous on purpose: the async UsbRequest API needs its URB cancelled and reaped
+ // before close, or the kernel later writes into freed memory (native SIGSEGV).
+ val read = synchronized(device.ioLock) {
+ device.connection.bulkTransfer(
+ device.driver.readEndpoint,
+ device.readBuffer,
+ device.readBuffer.size,
+ timeout,
+ )
+ }
+ when {
+ read > 0 -> readOk(device.readBuffer.copyOf(read))
+ read == 0 -> readOk(byteArrayOf())
+ device.detached || usbManager.deviceList[path] == null ->
+ readFail("USB device detached: '$path'", JadeTransportErrorCode.DISCONNECTED)
+ // A timeout with nothing received is the normal state while the user reads the device.
+ else -> readOk(byteArrayOf())
+ }
+ } catch (e: Exception) {
+ Logger.error("USB read failed", e, context = TAG)
+ readFail(e.message ?: "Unknown error", null)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun writeUsbChunk(path: String, data: ByteArray): JadeTransportResult {
+ val device = usbConnections[path]
+ ?: return fail("Device not open: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+ if (device.detached) return fail("USB device detached: '$path'", JadeTransportErrorCode.DISCONNECTED)
+
+ return try {
+ val written = synchronized(device.ioLock) {
+ device.connection.bulkTransfer(device.driver.writeEndpoint, data, data.size, USB_WRITE_TIMEOUT_MS)
+ }
+ if (written != data.size) {
+ val code = if (device.detached) JadeTransportErrorCode.DISCONNECTED else JadeTransportErrorCode.TIMEOUT
+ return fail("USB write wrote '$written' of '${data.size}' bytes", code)
+ }
+ Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG)
+ ok()
+ } catch (e: Exception) {
+ Logger.error("USB write failed", e, context = TAG)
+ fail(e.message ?: "Unknown error", null)
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Bluetooth (Nordic UART Service)
+ // ------------------------------------------------------------------
+
+ @SuppressLint("MissingPermission")
+ private fun scanBleDevices(timeoutMs: UInt): List {
+ if (bluetoothAdapter?.isEnabled != true) {
+ Logger.warn("Bluetooth is not enabled", context = TAG)
+ return emptyList()
+ }
+ val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return emptyList()
+
+ discoveredBleDevices.clear()
+ discoveredBleNames.clear()
+
+ val scanFilter = ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid(NUS_SERVICE_UUID))
+ .build()
+ val scanSettings = ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .build()
+
+ scanner.startScan(listOf(scanFilter), scanSettings, bleScanCallback)
+ Logger.debug("BLE scan started", context = TAG)
+ Thread.sleep(timeoutMs.toLong().coerceIn(BLE_SCAN_MIN_MS, BLE_SCAN_MAX_MS))
+ scanner.stopScan(bleScanCallback)
+ Logger.debug("BLE scan stopped", context = TAG)
+
+ return discoveredBleDevices.values.map { device ->
+ JadeNativeDevice(
+ path = blePath(device.address),
+ transport = JadeTransportKind.BLUETOOTH,
+ name = discoveredBleNames[device.address] ?: device.name ?: "Jade",
+ serialNumber = null,
+ )
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private val bleScanCallback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ val device = result.device
+ val address = device.address
+ if (discoveredBleDevices.putIfAbsent(address, device) == null) {
+ val name = result.scanRecord?.deviceName ?: device.name
+ name?.let { discoveredBleNames[address] = it }
+ Logger.debug("BLE device found: '$address' ('$name')", context = TAG)
+ }
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ Logger.warn("BLE scan failed: '$errorCode'", context = TAG)
+ }
+ }
+
+ @Suppress("ReturnCount")
+ @SuppressLint("MissingPermission")
+ private fun waitForBonding(device: BluetoothDevice, address: String): JadeTransportResult? {
+ when (device.bondState) {
+ BluetoothDevice.BOND_BONDED -> {
+ Logger.info("Device already bonded: '$address'", context = TAG)
+ return null
+ }
+ BluetoothDevice.BOND_NONE -> {
+ Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG)
+ if (!device.createBond()) return fail("Failed to initiate bonding", null)
+ }
+ else -> Logger.info("Device is currently bonding, waiting: '$address'", context = TAG)
+ }
+ var attempts = 0
+ while (device.bondState != BluetoothDevice.BOND_BONDED && attempts < MAX_BOND_POLL_ATTEMPTS) {
+ Thread.sleep(BOND_POLL_INTERVAL_MS)
+ attempts++
+ if (device.bondState == BluetoothDevice.BOND_NONE) return fail("Bonding failed or rejected", null)
+ }
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
+ return fail("Bonding timeout", JadeTransportErrorCode.TIMEOUT)
+ }
+ Logger.info("Device bonded successfully: '$address'", context = TAG)
+ return null
+ }
+
+ @Suppress("ReturnCount")
+ @SuppressLint("MissingPermission")
+ private fun openBleDevice(path: String): JadeTransportResult {
+ bleConnections[path]?.takeIf { it.isConnected && it.writeCharacteristic != null }?.let {
+ it.readQueue.clear()
+ Logger.info("Reused open BLE device '$path'", context = TAG)
+ return ok()
+ }
+
+ val address = path.removePrefix(BLE_PATH_PREFIX)
+ // A scan right after a disconnect often finds nothing yet, so resolve the address directly.
+ val device = discoveredBleDevices[address]
+ ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull()
+ ?: return fail("Device not found: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+
+ bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) }
+
+ waitForBonding(device, address)?.let { return it }
+
+ val connectionLatch = CountDownLatch(1)
+ val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
+ bleConnections[path] = BleConnection(gatt = gatt, connectionLatch = connectionLatch)
+
+ if (!connectionLatch.await(BLE_CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ disconnectBleDevice(path)
+ return fail("BLE connection timeout", JadeTransportErrorCode.TIMEOUT)
+ }
+ val connection = bleConnections[path]
+ if (connection == null || !connection.isConnected) {
+ disconnectBleDevice(path)
+ return fail("Failed to connect", null)
+ }
+
+ // A 30 KB PSBT is dozens of write-with-response round trips, each of which has to land well
+ // inside the firmware's two second inter-chunk window.
+ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+ connection.readQueue.clear()
+ Logger.info("Opened BLE device '$path' with MTU '${connection.mtu}'", context = TAG)
+ return ok()
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ @SuppressLint("MissingPermission")
+ private fun disconnectBleDevice(path: String): JadeTransportResult {
+ val connection = bleConnections[path] ?: return ok()
+ userInitiatedCloseSet.add(path)
+ return try {
+ // Disconnect whenever the link came up, even if setup failed afterwards: closing the
+ // client alone can leave the Jade's single connection slot occupied until it reboots.
+ if (connection.linkUp) {
+ val disconnectLatch = CountDownLatch(1)
+ connection.disconnectLatch = disconnectLatch
+ connection.gatt.disconnect()
+ if (!disconnectLatch.await(BLE_DISCONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ Logger.warn("BLE disconnect timeout, forcing close: '$path'", context = TAG)
+ }
+ }
+ bleConnections.remove(path)
+ connection.isConnected = false
+ connection.gatt.close()
+ connection.readQueue.clear()
+ Logger.info("Closed BLE device '$path'", context = TAG)
+ ok()
+ } catch (e: Exception) {
+ Logger.error("BLE close failed", e, context = TAG)
+ fail(e.message ?: "BLE close failed", null)
+ } finally {
+ userInitiatedCloseSet.remove(path)
+ }
+ }
+
+ private fun readBleChunk(path: String, timeoutMs: UInt): JadeTransportReadResult {
+ val connection = bleConnections[path]
+ ?: return readFail("Device not open: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+ if (!connection.isConnected) return readFail("BLE disconnected: '$path'", JadeTransportErrorCode.DISCONNECTED)
+
+ val data = connection.readQueue.poll(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
+ return when {
+ data != null -> readOk(data)
+ !connection.isConnected -> readFail("BLE disconnected: '$path'", JadeTransportErrorCode.DISCONNECTED)
+ else -> readOk(byteArrayOf())
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught", "ReturnCount")
+ @SuppressLint("MissingPermission")
+ private fun writeBleChunk(path: String, data: ByteArray): JadeTransportResult {
+ val connection = bleConnections[path]
+ ?: return fail("Device not open: '$path'", JadeTransportErrorCode.NOT_CONNECTED)
+ val writeChar = connection.writeCharacteristic
+ ?: return fail("Write characteristic not available", JadeTransportErrorCode.NOT_CONNECTED)
+ if (!connection.isConnected) return fail("BLE disconnected: '$path'", JadeTransportErrorCode.DISCONNECTED)
+
+ return try {
+ val writeLatch = CountDownLatch(1)
+ connection.writeLatch = writeLatch
+ connection.writeStatus = BluetoothGatt.GATT_FAILURE
+
+ var started = startCharacteristicWrite(connection.gatt, writeChar, data)
+ if (!started) {
+ // The GATT stack is still busy with the previous acknowledgement; one short retry.
+ Thread.sleep(BLE_WRITE_BUSY_RETRY_DELAY_MS)
+ started = startCharacteristicWrite(connection.gatt, writeChar, data)
+ }
+ if (!started) return fail("BLE write initiation failed", null)
+
+ if (!writeLatch.await(BLE_WRITE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ // The very first write stalling on a bonded device means the Jade rejected the stored
+ // key and the phone's re-pairing was not confirmed: only a fresh bond fixes that.
+ val bonded = connection.gatt.device.bondState == BluetoothDevice.BOND_BONDED
+ if (connection.writesCompleted == 0 && bonded) {
+ Logger.warn("BLE write stalled on a bonded Jade; the bond is stale: '$path'", context = TAG)
+ return fail(STALE_BOND_ERROR, null)
+ }
+ return fail("BLE write timeout", JadeTransportErrorCode.TIMEOUT)
+ }
+ if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) {
+ val code = if (connection.isConnected) null else JadeTransportErrorCode.DISCONNECTED
+ return fail("BLE write failed with status '${connection.writeStatus}'", code)
+ }
+ connection.writesCompleted++
+ Logger.debug("BLE wrote '${data.size}' bytes to '$path'", context = TAG)
+ ok()
+ } catch (e: Exception) {
+ Logger.error("BLE write failed", e, context = TAG)
+ fail(e.message ?: "Write failed", null)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun startCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ data: ByteArray,
+ ): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeCharacteristic(
+ characteristic,
+ data,
+ BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
+ ) == BluetoothStatusCodes.SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ run {
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ characteristic.value = data
+ gatt.writeCharacteristic(characteristic)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun enableNotifications(gatt: BluetoothGatt, notifyChar: BluetoothGattCharacteristic): Boolean {
+ if (!gatt.setCharacteristicNotification(notifyChar, true)) return false
+ val descriptor = notifyChar.getDescriptor(CCCD_UUID) ?: return false
+ // The firmware answers "request not supported" to a subscription of the wrong kind, so ask
+ // for whichever of the two the characteristic offers, preferring notifications.
+ val supportsNotify = notifyChar.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
+ val value = if (supportsNotify) {
+ BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ } else {
+ BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
+ }
+ Logger.debug(
+ "Subscribing to Jade TX with properties '0x${Integer.toHexString(notifyChar.properties)}'",
+ context = TAG,
+ )
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ run {
+ descriptor.value = value
+ gatt.writeDescriptor(descriptor)
+ }
+ }
+ }
+
+ private fun onNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
+ if (characteristic.uuid != NUS_NOTIFY_CHAR_UUID || value.isEmpty()) return
+ bleConnections[blePath(gatt.device.address)]?.readQueue?.offer(value.copyOf())
+ }
+
+ private fun finishConnect(connection: BleConnection, connected: Boolean) {
+ connection.isConnected = connected
+ connection.connectionLatch?.countDown()
+ }
+
+ @SuppressLint("MissingPermission")
+ private val gattCallback = object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
+ val path = blePath(gatt.device.address)
+ val connection = bleConnections[path]
+ if (status != BluetoothGatt.GATT_SUCCESS || newState == BluetoothProfile.STATE_DISCONNECTED) {
+ Logger.debug("BLE disconnected with status '$status' for '$path'", context = TAG)
+ connection?.let {
+ it.linkUp = false
+ it.isConnected = false
+ it.writeStatus = BluetoothGatt.GATT_FAILURE
+ releasePendingBleOperations(it.connectionLatch, it.writeLatch, it.disconnectLatch)
+ }
+ emitExternalDisconnect(path)
+ return
+ }
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ connection?.linkUp = true
+ Logger.debug("BLE connected, requesting MTU for '$path'", context = TAG)
+ if (!gatt.requestMtu(REQUESTED_ATT_MTU)) gatt.discoverServices()
+ }
+ }
+
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
+ val path = blePath(gatt.device.address)
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ bleConnections[path]?.mtu = mtu
+ Logger.info("Negotiated MTU '$mtu' for '$path'", context = TAG)
+ } else {
+ Logger.warn("MTU negotiation failed with status '$status' for '$path'", context = TAG)
+ }
+ gatt.discoverServices()
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+ val path = blePath(gatt.device.address)
+ val connection = bleConnections[path] ?: return
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Logger.error("Service discovery failed with status '$status' for '$path'", context = TAG)
+ finishConnect(connection, connected = false)
+ return
+ }
+ val service = gatt.getService(NUS_SERVICE_UUID)
+ val writeChar = service?.getCharacteristic(NUS_WRITE_CHAR_UUID)
+ val notifyChar = service?.getCharacteristic(NUS_NOTIFY_CHAR_UUID)
+ if (writeChar == null || notifyChar == null) {
+ Logger.error("Jade UART service not found on '$path'", context = TAG)
+ finishConnect(connection, connected = false)
+ return
+ }
+ if (writeChar.properties and BluetoothGattCharacteristic.PROPERTY_WRITE == 0) {
+ // Write-without-response silently drops chunks on the ESP32 GATT stack.
+ Logger.error("Jade write characteristic lacks write-with-response on '$path'", context = TAG)
+ finishConnect(connection, connected = false)
+ return
+ }
+ connection.writeCharacteristic = writeChar
+ if (!enableNotifications(gatt, notifyChar)) {
+ Logger.error("Failed to enable notifications on '$path'", context = TAG)
+ finishConnect(connection, connected = false)
+ }
+ }
+
+ override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
+ val path = blePath(gatt.device.address)
+ val connection = bleConnections[path] ?: return
+ val success = status == BluetoothGatt.GATT_SUCCESS
+ if (!success) Logger.warn("CCCD write failed with status '$status' for '$path'", context = TAG)
+ finishConnect(connection, connected = success)
+ }
+
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ ) {
+ onNotification(gatt, characteristic, value)
+ }
+
+ @Deprecated("Replaced on API 33 by the overload carrying the value")
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
+ // API 33 delivers the value through the overload above; this one only serves older releases.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
+ @Suppress("DEPRECATION")
+ val value = characteristic.value ?: return
+ onNotification(gatt, characteristic, value)
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ val connection = bleConnections[blePath(gatt.device.address)] ?: return
+ connection.writeStatus = status
+ connection.writeLatch?.countDown()
+ }
+ }
+
+ private fun emitExternalDisconnect(path: String) {
+ if (!userInitiatedCloseSet.remove(path)) {
+ _externalDisconnect.tryEmit(path)
+ }
+ }
+
+ private fun isBlePath(path: String) = path.startsWith(BLE_PATH_PREFIX)
+
+ private fun blePath(address: String) = "$BLE_PATH_PREFIX$address"
+}
+
+internal enum class UsbDriverKind { CP210X, CDC_ACM }
+
+/** The interfaces and bulk endpoints a Jade's USB bridge exposes, chosen from its descriptors. */
+internal data class UsbDriverSelection(
+ val kind: UsbDriverKind,
+ val dataInterface: UsbInterface,
+ /** CDC only: the communication-class interface that takes the line requests. */
+ val controlInterface: UsbInterface?,
+ val readEndpoint: UsbEndpoint,
+ val writeEndpoint: UsbEndpoint,
+)
+
+private data class BulkEndpoints(val read: UsbEndpoint, val write: UsbEndpoint)
+
+private fun UsbInterface.bulkEndpoints(): BulkEndpoints? {
+ var read: UsbEndpoint? = null
+ var write: UsbEndpoint? = null
+ for (i in 0 until endpointCount) {
+ val endpoint = getEndpoint(i)
+ if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) continue
+ when (endpoint.direction) {
+ UsbConstants.USB_DIR_IN -> read = endpoint
+ UsbConstants.USB_DIR_OUT -> write = endpoint
+ }
+ }
+ val readEndpoint = read ?: return null
+ val writeEndpoint = write ?: return null
+ return BulkEndpoints(read = readEndpoint, write = writeEndpoint)
+}
+
+/**
+ * Picks the driver by interface class rather than product id, so every Espressif layout (native CDC
+ * or the ROM serial/JTAG port) resolves to CDC-ACM and only the CP210x bridge takes the vendor path.
+ */
+internal fun selectUsbDriver(device: UsbDevice): UsbDriverSelection? {
+ val interfaces = (0 until device.interfaceCount).map { device.getInterface(it) }
+ interfaces.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA }?.let { data ->
+ val endpoints = data.bulkEndpoints() ?: return@let
+ return UsbDriverSelection(
+ kind = UsbDriverKind.CDC_ACM,
+ dataInterface = data,
+ controlInterface = interfaces.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_COMM },
+ readEndpoint = endpoints.read,
+ writeEndpoint = endpoints.write,
+ )
+ }
+ val cp210x = device.hwUsbId() == HwUsbId.JADE_CP210X
+ if (!cp210x) return null
+ val data = interfaces.firstOrNull() ?: return null
+ val endpoints = data.bulkEndpoints() ?: return null
+ return UsbDriverSelection(
+ kind = UsbDriverKind.CP210X,
+ dataInterface = data,
+ controlInterface = null,
+ readEndpoint = endpoints.read,
+ writeEndpoint = endpoints.write,
+ )
+}
diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt
index e9cf39cc78..e9243fe82e 100644
--- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt
+++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt
@@ -1,7 +1,6 @@
package to.bitkit.services
import android.annotation.SuppressLint
-import android.app.PendingIntent
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
@@ -14,10 +13,7 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
-import android.content.BroadcastReceiver
import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
@@ -27,7 +23,6 @@ import android.hardware.usb.UsbManager
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
-import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.synonym.bitkitcore.NativeDeviceInfo
import com.synonym.bitkitcore.TrezorCallMessageResult
@@ -618,59 +613,17 @@ class TrezorTransport @Inject constructor(
return File(credentialDir, "$sanitizedId.json")
}
- /**
- * Request USB permission for a device and block until the user responds.
- * Returns true if permission was granted, false otherwise.
- *
- * This uses a BroadcastReceiver + CountDownLatch pattern because openDevice
- * runs on a background thread (Rust FFI callback), not the main thread.
- */
- @Suppress("TooGenericExceptionCaught")
- private fun requestUsbPermission(device: UsbDevice): Boolean {
- val latch = CountDownLatch(1)
- var granted = false
-
- val receiver = object : BroadcastReceiver() {
- override fun onReceive(ctx: Context, intent: Intent) {
- if (intent.action == ACTION_USB_PERMISSION) {
- granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
- latch.countDown()
- }
- }
- }
-
- val permissionIntent = PendingIntent.getBroadcast(
- context,
- 0,
- Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) },
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
- )
-
- ContextCompat.registerReceiver(
- context,
- receiver,
- IntentFilter(ACTION_USB_PERMISSION),
- ContextCompat.RECEIVER_NOT_EXPORTED,
+ private val usbPermissionRequester by lazy {
+ UsbPermissionRequester(
+ context = context,
+ usbManager = usbManager,
+ action = ACTION_USB_PERMISSION,
+ timeoutMs = USB_PERMISSION_TIMEOUT_MS,
)
-
- try {
- Logger.info("Requesting USB permission for '${device.deviceName}'", context = TAG)
- usbManager.requestPermission(device, permissionIntent)
-
- val responded = latch.await(USB_PERMISSION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
- if (!responded) {
- Logger.warn("USB permission request timed out", context = TAG)
- return false
- }
-
- val status = if (granted) "granted" else "denied"
- Logger.info("USB permission '$status' for '${device.deviceName}'", context = TAG)
- return granted
- } finally {
- try { context.unregisterReceiver(receiver) } catch (_: Exception) {}
- }
}
+ private fun requestUsbPermission(device: UsbDevice): Boolean = usbPermissionRequester.request(device)
+
private data class UsbEndpoints(val read: UsbEndpoint, val write: UsbEndpoint)
private fun findUsbEndpoints(usbInterface: UsbInterface): UsbEndpoints? {
diff --git a/app/src/main/java/to/bitkit/services/UsbPermissionRequester.kt b/app/src/main/java/to/bitkit/services/UsbPermissionRequester.kt
new file mode 100644
index 0000000000..9bbfb623d0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/UsbPermissionRequester.kt
@@ -0,0 +1,79 @@
+package to.bitkit.services
+
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbManager
+import androidx.core.content.ContextCompat
+import to.bitkit.utils.Logger
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Asks the user for USB access to one device and blocks until they answer. Built on a
+ * BroadcastReceiver plus a latch because the transports call it from a Rust FFI thread, never
+ * from the main thread. Each transport passes its own [action] so one vendor's grant can never be
+ * consumed by another vendor's receiver.
+ */
+class UsbPermissionRequester(
+ private val context: Context,
+ private val usbManager: UsbManager,
+ private val action: String,
+ private val timeoutMs: Long,
+) {
+ companion object {
+ private const val TAG = "UsbPermissionRequester"
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ fun request(device: UsbDevice): Boolean {
+ val latch = CountDownLatch(1)
+ var granted = false
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ if (intent.action == action) {
+ granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
+ latch.countDown()
+ }
+ }
+ }
+
+ val permissionIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(action).apply { setPackage(context.packageName) },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+
+ ContextCompat.registerReceiver(
+ context,
+ receiver,
+ IntentFilter(action),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+
+ try {
+ Logger.info("Requesting USB permission for '${device.deviceName}'", context = TAG)
+ usbManager.requestPermission(device, permissionIntent)
+
+ val responded = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+ if (!responded) {
+ Logger.warn("USB permission request timed out", context = TAG)
+ return false
+ }
+
+ val status = if (granted) "granted" else "denied"
+ Logger.info("USB permission '$status' for '${device.deviceName}'", context = TAG)
+ return granted
+ } finally {
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (_: Exception) {
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index d3ad823396..1223fae457 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -39,9 +39,11 @@ import to.bitkit.androidServices.LightningNodeService.Companion.ACTION_START_SER
import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.SamRockSetupRequest
+import to.bitkit.services.JadeTransport
import to.bitkit.ui.components.AuthCheckView
import to.bitkit.ui.components.IsOnlineTracker
import to.bitkit.ui.components.ToastOverlay
+import to.bitkit.ui.components.modelNameRes
import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen
import to.bitkit.ui.onboarding.IntroScreen
import to.bitkit.ui.onboarding.OnboardingSlidesScreen
@@ -56,6 +58,8 @@ import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.ui.utils.composableWithDefaultTransitions
import to.bitkit.ui.utils.enableAppEdgeToEdge
+import to.bitkit.ui.utils.hwVendorOrNull
+import to.bitkit.ui.utils.isHwBootloader
import to.bitkit.utils.Logger
import to.bitkit.viewmodels.ActivityListViewModel
import to.bitkit.viewmodels.AppViewModel
@@ -66,12 +70,7 @@ import to.bitkit.viewmodels.MainScreenEffect
import to.bitkit.viewmodels.SettingsViewModel
import to.bitkit.viewmodels.TransferViewModel
import to.bitkit.viewmodels.WalletViewModel
-
-private const val TREZOR_WEBUSB_VENDOR_ID = 0x1209
-private const val TREZOR_WEBUSB_FIRMWARE_PRODUCT_ID = 0x53C1
-private const val TREZOR_WEBUSB_BOOTLOADER_PRODUCT_ID = 0x53C0
-private const val TREZOR_LEGACY_VENDOR_ID = 0x534C
-private const val TREZOR_LEGACY_PRODUCT_ID = 0x0001
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
@@ -79,6 +78,9 @@ class MainActivity : FragmentActivity() {
const val KEY_CONSUMED_LAUNCH_INTENT = "consumed_launch_intent"
}
+ @Inject
+ lateinit var jadeTransport: JadeTransport
+
private val appViewModel by viewModels()
private val walletViewModel by viewModels()
private val blocktankViewModel by viewModels()
@@ -252,11 +254,12 @@ class MainActivity : FragmentActivity() {
appViewModel.onUsbDeviceAttached()
return
}
- if (!device.isSupportedTrezorDevice()) return
+ val vendor = device.hwVendorOrNull() ?: return
appViewModel.onUsbDeviceAttached(
- deviceId = device.deviceName.takeUnless { device.isTrezorBootloader() },
- deviceModel = getString(R.string.hardware__device_model_trezor),
+ deviceId = device.deviceName.takeUnless { device.isHwBootloader() },
+ deviceModel = getString(vendor.modelNameRes()),
+ vendor = vendor,
)
}
@@ -269,6 +272,9 @@ class MainActivity : FragmentActivity() {
override fun onDestroy() {
super.onDestroy()
+ // A Jade left with an open Bluetooth link when the process dies can refuse connections until
+ // it is power-cycled, so release every link when the activity is going away for good.
+ if (isFinishing) jadeTransport.closeAllConnections()
if (!settingsViewModel.notificationsGranted.value) {
stopForegroundService()
}
@@ -313,15 +319,6 @@ internal fun Intent?.launchKey(): String? {
private fun Intent.usbDevice(): UsbDevice? =
IntentCompat.getParcelableExtra(this, UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
-private fun UsbDevice.isSupportedTrezorDevice() = isTrezorFirmwareDevice() || isTrezorBootloader()
-
-private fun UsbDevice.isTrezorFirmwareDevice() =
- (vendorId == TREZOR_WEBUSB_VENDOR_ID && productId == TREZOR_WEBUSB_FIRMWARE_PRODUCT_ID) ||
- (vendorId == TREZOR_LEGACY_VENDOR_ID && productId == TREZOR_LEGACY_PRODUCT_ID)
-
-private fun UsbDevice.isTrezorBootloader() =
- vendorId == TREZOR_WEBUSB_VENDOR_ID && productId == TREZOR_WEBUSB_BOOTLOADER_PRODUCT_ID
-
@Composable
private fun OnboardingNav(
startupNavController: NavHostController,
diff --git a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt
index 7f0c520bb6..b694e7727a 100644
--- a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt
+++ b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt
@@ -1,6 +1,7 @@
package to.bitkit.ui.components
import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -19,6 +20,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import to.bitkit.R
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.TransportType
import to.bitkit.ui.theme.Colors
@@ -44,6 +46,37 @@ private const val HW_DEVICE_LEDGER_BLEED_RATIO = 53f / 375f
/** Vertical stagger between the two device illustrations, as a fraction of the sheet width. */
private const val HW_DEVICE_STAGGER_RATIO = 12f / 375f
+/** The device illustration shown for a vendor; the Jade one is a placeholder until design supplies the asset. */
+@DrawableRes
+fun HwWalletVendor.illustrationRes(): Int = when (this) {
+ HwWalletVendor.TREZOR -> R.drawable.trezor
+ HwWalletVendor.BLOCKSTREAM -> R.drawable.jade_placeholder
+}
+
+@StringRes
+fun HwWalletVendor.modelNameRes(): Int = when (this) {
+ HwWalletVendor.TREZOR -> R.string.hardware__device_model_trezor
+ HwWalletVendor.BLOCKSTREAM -> R.string.hardware__device_model_jade
+}
+
+@StringRes
+fun HwWalletVendor.foundHeaderRes(): Int = when (this) {
+ HwWalletVendor.TREZOR -> R.string.hardware__found_header
+ HwWalletVendor.BLOCKSTREAM -> R.string.hardware__found_header_jade
+}
+
+@StringRes
+fun HwWalletVendor.pairedHeaderRes(): Int = when (this) {
+ HwWalletVendor.TREZOR -> R.string.hardware__paired_header
+ HwWalletVendor.BLOCKSTREAM -> R.string.hardware__paired_header_jade
+}
+
+@StringRes
+fun HwWalletVendor.sendOpenConnectRes(): Int = when (this) {
+ HwWalletVendor.TREZOR -> R.string.hardware__send_open_connect
+ HwWalletVendor.BLOCKSTREAM -> R.string.hardware__send_open_connect_jade
+}
+
@Composable
fun HwDeviceIllustrations(modifier: Modifier = Modifier) {
BoxWithConstraints(modifier) {
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 65901f5a12..1ac9256741 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
@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -21,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.synonym.bitkitcore.IBtOrder
import to.bitkit.R
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.safe
import to.bitkit.ui.components.ButtonSize
import to.bitkit.ui.components.Display
@@ -30,6 +32,7 @@ import to.bitkit.ui.components.HardwareTransferIllustration
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SIGN_VISUAL_TOP_RATIO
import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.components.illustrationRes
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
@@ -49,6 +52,10 @@ fun SpendingHwSignScreen(
onAdvancedClick: () -> Unit,
) {
val state by viewModel.spendingUiState.collectAsStateWithLifecycle()
+ val hardwareWallets by viewModel.hardwareWallets.collectAsStateWithLifecycle()
+ val vendor = remember(hardwareWallets, walletId) {
+ hardwareWallets.firstOrNull { it.id == walletId }?.vendor ?: HwWalletVendor.TREZOR
+ }
val order = state.order ?: run {
onCloseClick()
@@ -70,6 +77,7 @@ fun SpendingHwSignScreen(
isAdvanced = state.isAdvanced,
isSigning = state.isSigning,
hasPendingBroadcast = state.hasPendingHwBroadcast,
+ vendor = vendor,
onBackClick = onBackClick,
onLearnMoreClick = onLearnMoreClick,
onAdvancedClick = onAdvancedClick,
@@ -93,6 +101,7 @@ private fun Content(
isAdvanced: Boolean = false,
isSigning: Boolean = false,
hasPendingBroadcast: Boolean = false,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
onBackClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {},
onAdvancedClick: () -> Unit = {},
@@ -107,7 +116,7 @@ private fun Content(
)
Box(modifier = Modifier.fillMaxSize()) {
HardwareTransferIllustration(
- drawableRes = R.drawable.trezor,
+ drawableRes = vendor.illustrationRes(),
topRatio = SIGN_VISUAL_TOP_RATIO,
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt
index 8de971c067..7c839e528c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt
@@ -49,6 +49,7 @@ import to.bitkit.ui.components.TabBar
import to.bitkit.ui.components.TertiaryButton
import to.bitkit.ui.components.TopBarSpacer
import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.components.illustrationRes
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.screens.wallets.activity.components.activityListGroupedItems
@@ -129,7 +130,7 @@ private fun HardwareWalletContent(
.hazeSource(hazeState)
) {
Image(
- painter = painterResource(id = R.drawable.trezor),
+ painter = painterResource(id = wallet.vendor.illustrationRes()),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt
index 4c7e2fccfe..29bf9c3175 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt
@@ -22,6 +22,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
import to.bitkit.ui.components.CaptionB
import to.bitkit.ui.shared.modifiers.clickableAlpha
import to.bitkit.ui.theme.Colors
@@ -33,6 +35,8 @@ fun CustomTabRowWithSpacing(
onTabChange: (T) -> Unit,
modifier: Modifier = Modifier,
selectedColor: Color = Colors.Brand,
+ /** Labels that replace a tab's own text, for tabs whose name depends on runtime data. */
+ labelOverrides: ImmutableMap = persistentMapOf(),
) {
Column(modifier = modifier) {
Row(
@@ -55,7 +59,7 @@ fun CustomTabRowWithSpacing(
.testTag("Tab-${tab.name.lowercase()}")
) {
CaptionB(
- tab.uiText,
+ labelOverrides[tab] ?: tab.uiText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isSelected) Colors.White else Colors.White50
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt
index be9d64c1bd..f10c630dd1 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt
@@ -15,9 +15,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import to.bitkit.R
-import to.bitkit.ext.isTrezorDeviceBusy
-import to.bitkit.ext.isTrezorFirmwareError
-import to.bitkit.ext.isTrezorUserCancellation
+import to.bitkit.ext.isHwDeviceBusy
+import to.bitkit.ext.isHwFirmwareError
+import to.bitkit.ext.isHwUserCancellation
import to.bitkit.models.HwReceiveAddress
import to.bitkit.models.Toast
import to.bitkit.repositories.HwPassphraseMismatchError
@@ -25,6 +25,7 @@ import to.bitkit.repositories.HwPassphraseRequiredError
import to.bitkit.repositories.HwReceiveAddressMismatchError
import to.bitkit.repositories.HwWalletRepo
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.HwErrorPresenter
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@@ -109,7 +110,8 @@ class HwReceiveViewModel @Inject constructor(
return@launch
}
runCatching {
- withTimeout(VERIFY_TIMEOUT) {
+ // Verification reconnects first, and a Jade reconnect may wait for its PIN.
+ withTimeout(VERIFY_TIMEOUT + hwWalletRepo.reconnectTimeout(walletId)) {
hwWalletRepo.verifyReceiveAddress(walletId, address).getOrThrow()
}
}.onFailure {
@@ -180,15 +182,15 @@ class HwReceiveViewModel @Inject constructor(
private suspend fun handleVerifyFailure(error: Throwable) {
when {
- error.isTrezorUserCancellation() -> Unit
+ error.isHwUserCancellation() -> Unit
generateSequence(error) { it.cause }.any { it is HwPassphraseRequiredError } -> {
_uiState.update { it.copy(isPassphraseRequired = true) }
}
- error.isTrezorDeviceBusy() -> ToastEventBus.send(
+ error.isHwDeviceBusy() -> ToastEventBus.send(
type = Toast.ToastType.INFO,
- title = context.getString(R.string.hardware__device_busy),
+ title = HwErrorPresenter.userMessage(context, error),
)
- error.isTrezorFirmwareError() -> ToastEventBus.send(
+ error.isHwFirmwareError() -> ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = context.getString(R.string.hardware__connect_error),
@@ -203,7 +205,15 @@ class HwReceiveViewModel @Inject constructor(
title = context.getString(R.string.common__error),
description = context.getString(R.string.hardware__verify_address_error),
)
- else -> ToastEventBus.send(error)
+ else -> ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = HwErrorPresenter.userMessage(
+ context = context,
+ error = error,
+ fallback = context.getString(R.string.hardware__connect_error),
+ ),
+ )
}
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt
index 9a098c1f2d..0c1dbc2361 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt
@@ -41,7 +41,7 @@ fun getInvoiceForTab(
?: bolt11.takeIf { isNodeRunning }.orEmpty()
}
- ReceiveTab.TREZOR -> hardwareAddress.takeIf(String::isNotBlank)?.let { address ->
+ ReceiveTab.HARDWARE -> hardwareAddress.takeIf(String::isNotBlank)?.let { address ->
Bip21Utils.buildBip21Url(
bitcoinAddress = address,
amountSats = hardwareAmountSats?.takeUnless { it == 0uL },
@@ -105,6 +105,6 @@ fun getQrLogoResource(tab: ReceiveTab): Int {
ReceiveTab.SAVINGS -> R.drawable.ic_btc_circle
ReceiveTab.AUTO -> R.drawable.ic_unified_circle
ReceiveTab.SPENDING -> R.drawable.ic_ln_circle
- ReceiveTab.TREZOR -> R.drawable.ic_btc_circle_blue
+ ReceiveTab.HARDWARE -> R.drawable.ic_btc_circle_blue
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt
index 6cf92e3294..79b956aead 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt
@@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Devices.NEXUS_5
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -97,6 +98,8 @@ fun ReceiveQrScreen(
modifier: Modifier = Modifier,
initialTab: ReceiveTab? = null,
hardwareWalletId: String? = null,
+ /** Vendor name shown on the hardware tab, e.g. "Trezor" or "Jade". */
+ hardwareTabLabel: String? = null,
hardwareReceiveState: HwReceiveUiState = HwReceiveUiState(),
onLoadHardwareAddress: (String) -> Unit = {},
onRetryHardwareAddress: () -> Unit = {},
@@ -112,7 +115,7 @@ fun ReceiveQrScreen(
val visibleTabs = remember(hasUsableChannels, hardwareWalletId) {
buildList {
if (hardwareWalletId != null) {
- add(ReceiveTab.TREZOR)
+ add(ReceiveTab.HARDWARE)
}
add(ReceiveTab.SAVINGS)
if (hasUsableChannels) {
@@ -213,7 +216,7 @@ fun ReceiveQrScreen(
LaunchedEffect(selectedTab, hardwareWalletId) {
showDetails = false
- if (selectedTab == ReceiveTab.TREZOR && hardwareWalletId != null) {
+ if (selectedTab == ReceiveTab.HARDWARE && hardwareWalletId != null) {
onLoadHardwareAddress(hardwareWalletId)
}
}
@@ -240,6 +243,9 @@ fun ReceiveQrScreen(
tabs = visibleTabs,
currentTabIndex = visibleTabs.indexOf(selectedTab),
selectedColor = Colors.White,
+ labelOverrides = remember(hardwareTabLabel) {
+ hardwareTabLabel?.let { persistentMapOf(ReceiveTab.HARDWARE to it) } ?: persistentMapOf()
+ },
onTabChange = { tab ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
val newIndex = visibleTabs.indexOf(tab)
@@ -282,7 +288,7 @@ fun ReceiveQrScreen(
)
}
- tab == ReceiveTab.TREZOR && hardwareReceiveState.address == null -> {
+ tab == ReceiveTab.HARDWARE && hardwareReceiveState.address == null -> {
HardwareAddressLoadingView(
isLoading = hardwareReceiveState.isLoadingAddress,
hasFailed = hardwareReceiveState.addressLoadFailed,
@@ -300,7 +306,7 @@ fun ReceiveQrScreen(
onClickEditInvoice = onClickEditInvoice,
onClickHardwareEditInvoice = onClickHardwareEditInvoice,
hardwareAddress = hardwareReceiveState.address?.address,
- hardwareInvoice = invoicesByTab[ReceiveTab.TREZOR].orEmpty(),
+ hardwareInvoice = invoicesByTab[ReceiveTab.HARDWARE].orEmpty(),
modifier = Modifier.weight(1f)
)
}
@@ -313,7 +319,7 @@ fun ReceiveQrScreen(
walletState.onchainAddress,
)
- ReceiveTab.TREZOR -> invoice.takeIf { '?' in it }
+ ReceiveTab.HARDWARE -> invoice.takeIf { '?' in it }
?: hardwareReceiveState.address?.address.orEmpty()
else -> invoice
@@ -323,7 +329,7 @@ fun ReceiveQrScreen(
uri = invoice,
copyText = copyText,
qrLogoPainter = painterResource(getQrLogoResource(tab)),
- onClickEditInvoice = if (tab == ReceiveTab.TREZOR) {
+ onClickEditInvoice = if (tab == ReceiveTab.HARDWARE) {
onClickHardwareEditInvoice
} else if (cjitInvoice.isNullOrEmpty()) {
onClickEditInvoice
@@ -375,7 +381,7 @@ fun ReceiveQrScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
- if (selectedTab == ReceiveTab.TREZOR) {
+ if (selectedTab == ReceiveTab.HARDWARE) {
SecondaryButton(
text = stringResource(R.string.hardware__verify_address),
enabled = hardwareReceiveState.address != null,
@@ -404,7 +410,7 @@ fun ReceiveQrScreen(
BottomButtonVariant.SHOW_DETAILS -> TertiaryButton(
text = stringResource(R.string.wallet__receive_show_details),
onClick = { showDetails = true },
- enabled = selectedTab != ReceiveTab.TREZOR || hardwareReceiveState.address != null,
+ enabled = selectedTab != ReceiveTab.HARDWARE || hardwareReceiveState.address != null,
fullWidth = true,
modifier = Modifier
.padding(horizontal = 16.dp)
@@ -645,7 +651,7 @@ private fun ReceiveDetailsView(
}
}
- ReceiveTab.TREZOR -> {
+ ReceiveTab.HARDWARE -> {
hardwareAddress?.let { address ->
CopyAddressCard(
title = stringResource(R.string.wallet__receive_bitcoin_invoice),
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
index 96b2af06b5..deced57126 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
@@ -32,6 +32,7 @@ import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.repositories.PaykitPaymentRequestDraft
import to.bitkit.repositories.WalletState
import to.bitkit.ui.components.ConnectionIssuesView
+import to.bitkit.ui.components.modelNameRes
import to.bitkit.ui.navigateTo
import to.bitkit.ui.openNotificationSettings
import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen
@@ -143,6 +144,8 @@ fun ReceiveSheet(
},
initialTab = invoiceEditState.initialTab(hardwareWalletId),
hardwareWalletId = selectedHardwareWalletId,
+ hardwareTabLabel = hardwareWallets.firstOrNull { it.id == selectedHardwareWalletId }
+ ?.let { stringResource(it.vendor.modelNameRes()) },
hardwareReceiveState = hwReceiveState,
onLoadHardwareAddress = hwReceiveViewModel::loadAddress,
onRetryHardwareAddress = hwReceiveViewModel::retryAddress,
@@ -354,7 +357,7 @@ internal class ReceiveInvoiceEditState {
}
fun initialTab(hardwareWalletId: String?): ReceiveTab? =
- ReceiveTab.TREZOR.takeIf { hardwareWalletId != null || isHardwareInvoice }
+ ReceiveTab.HARDWARE.takeIf { hardwareWalletId != null || isHardwareInvoice }
}
@Composable
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt
index a862cc6aea..70186028af 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt
@@ -11,7 +11,9 @@ enum class ReceiveTab : TabItem {
SAVINGS,
AUTO,
SPENDING,
- TREZOR;
+
+ /** The paired hardware wallet; its label carries the vendor name (see [ReceiveQrScreen]). */
+ HARDWARE;
override val uiText: String
@Composable
@@ -19,7 +21,7 @@ enum class ReceiveTab : TabItem {
SAVINGS -> stringResource(R.string.wallet__receive_tab_savings)
AUTO -> stringResource(R.string.wallet__receive_tab_auto)
SPENDING -> stringResource(R.string.wallet__receive_tab_spending)
- TREZOR -> stringResource(R.string.hardware__device_model_trezor)
+ HARDWARE -> stringResource(R.string.hardware__receive_tab_hardware)
}
val accentColor: Color
@@ -27,6 +29,6 @@ enum class ReceiveTab : TabItem {
SAVINGS -> Colors.Brand
AUTO -> Colors.Brand
SPENDING -> Colors.Purple
- TREZOR -> Colors.Blue
+ HARDWARE -> Colors.Blue
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt
index 83ee6bf3e4..bd0a8fd22c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -18,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import to.bitkit.R
+import to.bitkit.models.HwWalletVendor
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.BodySSB
import to.bitkit.ui.components.BottomSheetPreview
@@ -26,6 +28,8 @@ import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.HardwareTransferIllustration
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.components.illustrationRes
+import to.bitkit.ui.components.sendOpenConnectRes
import to.bitkit.ui.scaffold.SheetTopBar
import to.bitkit.ui.screens.transfer.hardware.HwPassphrasePromptSheet
import to.bitkit.ui.shared.modifiers.sheetHeight
@@ -46,6 +50,10 @@ fun HwSendSignScreen(
onBack: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val wallets by viewModel.wallets.collectAsStateWithLifecycle()
+ val vendor = remember(wallets, walletId) {
+ wallets.firstOrNull { it.id == walletId }?.vendor ?: HwWalletVendor.TREZOR
+ }
val request = HwSendRequest(
walletId = walletId,
address = sendUiState.address,
@@ -66,6 +74,7 @@ fun HwSendSignScreen(
address = sendUiState.address,
isSigning = uiState.isSigning,
hasPendingBroadcast = uiState.hasPendingBroadcast,
+ vendor = vendor,
onBack = { if (!uiState.isSigning && !uiState.isBroadcastUnresolved) onBack() },
onOpenConnect = { viewModel.signAndBroadcast(request, prepareContactPayment) },
)
@@ -88,6 +97,7 @@ private fun HwSendSignContent(
isSigning: Boolean,
hasPendingBroadcast: Boolean,
modifier: Modifier = Modifier,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
onBack: () -> Unit = {},
onOpenConnect: () -> Unit = {},
) {
@@ -98,7 +108,7 @@ private fun HwSendSignContent(
.navigationBarsPadding()
) {
HardwareTransferIllustration(
- drawableRes = R.drawable.trezor,
+ drawableRes = vendor.illustrationRes(),
topRatio = SEND_SIGN_VISUAL_TOP_RATIO,
)
@@ -134,7 +144,7 @@ private fun HwSendSignContent(
FillHeight()
PrimaryButton(
text = stringResource(
- if (hasPendingBroadcast) R.string.common__retry else R.string.hardware__send_open_connect
+ if (hasPendingBroadcast) R.string.common__retry else vendor.sendOpenConnectRes()
),
enabled = !isSigning,
isLoading = isSigning,
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt
index be7b209c8a..c32d867536 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt
@@ -6,10 +6,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
@@ -17,14 +19,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import to.bitkit.R
import to.bitkit.ext.isBroadcastConnectivityFailure
-import to.bitkit.ext.isTrezorDeviceBusy
-import to.bitkit.ext.isTrezorFirmwareError
-import to.bitkit.ext.isTrezorSessionFailure
-import to.bitkit.ext.isTrezorUserCancellation
+import to.bitkit.ext.isHwDeviceBusy
+import to.bitkit.ext.isHwFirmwareError
+import to.bitkit.ext.isHwSessionFailure
+import to.bitkit.ext.isHwUserCancellation
import to.bitkit.ext.runSuspendCatching
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
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.HwPassphraseMismatchError
@@ -33,6 +36,7 @@ import to.bitkit.repositories.HwWalletRepo
import to.bitkit.repositories.PreActivityMetadataRepo
import to.bitkit.services.CoreService
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.HwErrorPresenter
import to.bitkit.utils.Logger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@@ -47,12 +51,14 @@ class HwSendViewModel @Inject constructor(
) : ViewModel() {
private companion object {
const val TAG = "HwSendViewModel"
- val RECONNECT_TIMEOUT = 30.seconds
val COMPOSE_TIMEOUT = 45.seconds
val SIGN_TIMEOUT = 120.seconds
val BROADCAST_TIMEOUT = 120.seconds
}
+ val wallets: StateFlow>
+ get() = hwWalletRepo.wallets
+
private val _uiState = MutableStateFlow(HwSendUiState())
val uiState = _uiState.asStateFlow()
@@ -211,14 +217,15 @@ class HwSendViewModel @Inject constructor(
private suspend fun sign(walletId: String, funding: HwFundingTransaction): HwFundingSignedTx {
val firstAttempt = runSuspendCatching { signWithTimeoutCleanup(walletId, funding) }
val error = firstAttempt.exceptionOrNull() ?: return firstAttempt.getOrThrow()
- if (!error.isTrezorSessionFailure()) throw error
+ if (!error.isHwSessionFailure()) throw error
ensureConnected(walletId)
return signWithTimeoutCleanup(walletId, funding)
}
private suspend fun ensureConnected(walletId: String) {
- withTimeout(RECONNECT_TIMEOUT) {
+ // A Jade reconnect may include entering the PIN on the device, so the budget is per vendor.
+ withTimeout(hwWalletRepo.reconnectTimeout(walletId)) {
hwWalletRepo.ensureConnected(walletId).getOrThrow()
}
}
@@ -268,17 +275,17 @@ class HwSendViewModel @Inject constructor(
private suspend fun handleFailure(error: Throwable, walletId: String) {
when {
- error.isTrezorUserCancellation() -> {
+ error.isHwUserCancellation() -> {
Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG)
}
generateSequence(error) { it.cause }.any { it is HwPassphraseRequiredError } -> {
_uiState.update { it.copy(isPassphraseRequired = true) }
}
- error.isTrezorDeviceBusy() -> ToastEventBus.send(
+ error.isHwDeviceBusy() -> ToastEventBus.send(
type = Toast.ToastType.INFO,
- title = context.getString(R.string.hardware__device_busy),
+ title = HwErrorPresenter.userMessage(context, error),
)
- error.isTrezorFirmwareError() -> ToastEventBus.send(
+ error.isHwFirmwareError() -> ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title),
description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description),
@@ -304,7 +311,15 @@ class HwSendViewModel @Inject constructor(
)
}
}
- ToastEventBus.send(error)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = HwErrorPresenter.userMessage(
+ context = context,
+ error = error,
+ fallback = context.getString(R.string.hardware__connect_error),
+ ),
+ )
}
}
}
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 22a3ebaa21..85deac9360 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
@@ -32,8 +32,10 @@ import kotlinx.serialization.Serializable
import to.bitkit.R
import to.bitkit.ext.isBluetoothEnabled
import to.bitkit.ext.startActivityAppSettings
+import to.bitkit.models.HwWalletVendor
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.components.SheetSize
+import to.bitkit.ui.components.modelNameRes
import to.bitkit.ui.navigateTo
import to.bitkit.ui.scaffold.AppAlertDialog
import to.bitkit.ui.shared.modifiers.sheetHeight
@@ -155,18 +157,21 @@ fun HardwareSheet(
}
composableWithDefaultTransitions { backStackEntry ->
val route = backStackEntry.toRoute()
- LaunchedEffect(route.deviceId, route.deviceModel) {
+ LaunchedEffect(route.deviceId, route.deviceModel, route.vendor) {
viewModel.onFoundRoute(
deviceId = route.deviceId,
deviceModel = route.deviceModel,
+ vendor = route.vendor,
)
}
val deviceModel = uiState.deviceModel.ifBlank {
- route.deviceModel.ifBlank { stringResource(R.string.hardware__device_model_trezor) }
+ route.deviceModel.ifBlank { stringResource(route.vendor.modelNameRes()) }
}
HwFoundSheet(
deviceModel = deviceModel,
+ vendor = uiState.vendor,
isConnecting = uiState.isConnecting,
+ isUnlocking = uiState.isUnlocking,
errorMessage = uiState.errorMessage,
onConnect = { viewModel.onConnectClick(route.deviceId) },
onCancel = {
@@ -247,6 +252,7 @@ private fun ConnectEffectHandler(
HardwareRoute.Found(
deviceId = effect.deviceId,
deviceModel = effect.deviceModel,
+ vendor = effect.vendor,
),
)
is HwConnectEffect.NavigateToPairCode -> navController.navigateTo(
@@ -281,6 +287,7 @@ sealed interface HardwareRoute {
data class Found(
val deviceId: String? = null,
val deviceModel: String = "",
+ val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
) : InternalOnly
@Serializable
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 2d8ebf2cda..02c6f96ad8 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
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.synonym.bitkitcore.TrezorFeatures
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
@@ -13,11 +12,15 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
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.ext.isHwDeviceBusy
+import to.bitkit.models.HwConnectedDevice
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.Toast
import to.bitkit.repositories.HwPassphraseAlreadyAddedError
import to.bitkit.repositories.HwPassphraseDisabledError
@@ -25,8 +28,8 @@ import to.bitkit.repositories.HwWalletRepo
import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH
import to.bitkit.repositories.resolveHwWalletName
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.HwErrorPresenter
import to.bitkit.utils.Logger
-import to.bitkit.utils.TrezorErrorPresenter
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@@ -49,8 +52,8 @@ class HwConnectViewModel @Inject constructor(
companion object {
private const val TAG = "HwConnectViewModel"
- /** Delay between scan attempts while searching for a nearby device. */
- private val SCAN_INTERVAL = 2.seconds
+ /** Delay between scan attempts; Android throttles apps that start Bluetooth scans too often. */
+ private val SCAN_INTERVAL = 4.seconds
/** Prefix used by Android USB attach intents for [android.hardware.usb.UsbDevice.deviceName]. */
private const val USB_DEVICE_PATH_PREFIX = "/dev/"
@@ -71,6 +74,7 @@ class HwConnectViewModel @Inject constructor(
init {
observePairingCode()
observeConnectedWallet()
+ observeUnlocking()
}
fun onIntroContinue(includeBluetooth: Boolean = true) {
@@ -84,7 +88,7 @@ class HwConnectViewModel @Inject constructor(
includeBluetoothInScan = true
}
- fun onFoundRoute(deviceId: String?, deviceModel: String) {
+ fun onFoundRoute(deviceId: String?, deviceModel: String, vendor: HwWalletVendor = HwWalletVendor.TREZOR) {
if (deviceId == null) return
searchJob?.cancel()
searchJob = null
@@ -92,7 +96,8 @@ class HwConnectViewModel @Inject constructor(
it.copy(
isSearching = false,
foundDeviceId = deviceId,
- deviceModel = deviceModel.ifBlank { resolveHwWalletName(label = null, model = null) },
+ deviceModel = deviceModel.ifBlank { resolveHwWalletName(label = null, model = null, vendor = vendor) },
+ vendor = vendor,
errorMessage = null,
)
}
@@ -109,49 +114,59 @@ class HwConnectViewModel @Inject constructor(
connectJob = viewModelScope.launch {
var resolvedDeviceId = deviceId
var resolvedDeviceModel = state.deviceModel
+ var resolvedVendor = state.vendor
if (shouldScanUsbBeforeConnect) {
hwWalletRepo.scan(includeBluetooth = false)
.onSuccess { devices ->
devices.firstOrNull { it.id == deviceId || it.path == deviceId }?.let { device ->
resolvedDeviceId = device.id
- resolvedDeviceModel = resolveHwWalletName(label = null, model = device.model)
+ resolvedVendor = device.vendor
+ resolvedDeviceModel = resolveHwWalletName(
+ label = null,
+ model = device.model,
+ vendor = device.vendor,
+ )
_uiState.update {
it.copy(
foundDeviceId = resolvedDeviceId,
deviceModel = resolvedDeviceModel,
+ vendor = resolvedVendor,
)
}
}
}
.onFailure { error ->
- onConnectFailed(resolvedDeviceId, resolvedDeviceModel, error)
+ onConnectFailed(resolvedDeviceId, resolvedDeviceModel, resolvedVendor, error)
return@launch
}
}
- hwWalletRepo.connect(resolvedDeviceId)
+ hwWalletRepo.connect(resolvedDeviceId, resolvedVendor)
.onSuccess { onConnected(resolvedDeviceId, it) }
- .onFailure { error -> onConnectFailed(resolvedDeviceId, resolvedDeviceModel, error) }
+ .onFailure { error -> onConnectFailed(resolvedDeviceId, resolvedDeviceModel, resolvedVendor, error) }
connectJob = null
}
}
- private fun onConnectFailed(deviceId: String, deviceModel: String, error: Throwable) {
+ private fun onConnectFailed(deviceId: String, deviceModel: String, vendor: HwWalletVendor, error: Throwable) {
_uiState.update {
it.copy(
isConnecting = false,
foundDeviceId = deviceId,
deviceModel = deviceModel,
- errorMessage = if (error.isTrezorDeviceBusy()) {
- TrezorErrorPresenter.userMessage(context, error)
- } else {
- context.getString(R.string.hardware__connect_error)
- },
+ vendor = vendor,
+ errorMessage = HwErrorPresenter.userMessage(
+ context = context,
+ error = error,
+ fallback = context.getString(R.string.hardware__connect_error),
+ ).takeIf { error.isHwDeviceBusy() || vendor == HwWalletVendor.BLOCKSTREAM }
+ ?: context.getString(R.string.hardware__connect_error),
)
}
setEffect(
HwConnectEffect.NavigateToFound(
deviceId = deviceId,
deviceModel = deviceModel,
+ vendor = vendor,
)
)
connectJob = null
@@ -175,6 +190,7 @@ class HwConnectViewModel @Inject constructor(
// Each identity is labelled on its own paired step, so persist the one being left before
// the next passphrase wallet takes over the field.
val state = _uiState.value
+ if (state.vendor != HwWalletVendor.TREZOR) return
state.pairedWalletId?.let { walletId ->
viewModelScope.launch { persistLabel(walletId, state.labelInput) }
}
@@ -244,7 +260,7 @@ class HwConnectViewModel @Inject constructor(
val description = when (error) {
is HwPassphraseDisabledError -> context.getString(R.string.hardware__passphrase_disabled)
is HwPassphraseAlreadyAddedError -> context.getString(R.string.hardware__passphrase_duplicate)
- else if error.isTrezorDeviceBusy() -> TrezorErrorPresenter.userMessage(context, error)
+ else if error.isHwDeviceBusy() -> HwErrorPresenter.userMessage(context, error)
else -> context.getString(R.string.hardware__passphrase_error)
}
ToastEventBus.send(
@@ -307,16 +323,17 @@ class HwConnectViewModel @Inject constructor(
val device = hwWalletRepo.deviceState.value.nearbyDevices.firstOrNull()
?: scanResult.getOrNull().orEmpty().firstOrNull { hwWalletRepo.hasKnownDevice(it.id) }
if (device != null) {
- val deviceModel = resolveHwWalletName(label = null, model = device.model)
+ val deviceModel = resolveHwWalletName(label = null, model = device.model, vendor = device.vendor)
_uiState.update {
it.copy(
isSearching = false,
foundDeviceId = device.id,
deviceModel = deviceModel,
+ vendor = device.vendor,
errorMessage = null,
)
}
- setEffect(HwConnectEffect.NavigateToFound(device.id, deviceModel))
+ setEffect(HwConnectEffect.NavigateToFound(device.id, deviceModel, device.vendor))
return@launch
}
delay(SCAN_INTERVAL)
@@ -324,27 +341,38 @@ class HwConnectViewModel @Inject constructor(
}
}
- private fun onConnected(deviceId: String, features: TrezorFeatures) {
+ private fun onConnected(deviceId: String, device: HwConnectedDevice) {
// The device may hold several identities, so take the one this session opened rather than
// any wallet sharing its transport id, and show the name it was already saved under.
- val walletId = hwWalletRepo.deviceState.value.connectedWalletId()
+ val walletId = device.walletId ?: hwWalletRepo.deviceState.value.connectedWalletId()
val wallet = walletId?.let { id -> hwWalletRepo.wallets.value.firstOrNull { it.id == id } }
- val name = wallet?.name ?: resolveHwWalletName(label = features.label, model = features.model)
+ val name = wallet?.name
+ ?: resolveHwWalletName(label = device.label, model = device.model, vendor = device.vendor)
labelInitialized = wallet != null
_uiState.update {
it.copy(
isConnecting = false,
- pairedDeviceId = deviceId,
+ pairedDeviceId = device.id,
pairedWalletId = walletId,
deviceName = name,
+ vendor = device.vendor,
balanceSats = wallet?.balanceSats ?: it.balanceSats,
labelInput = name,
errorMessage = null,
)
}
+ Logger.debug("Paired hardware device '$deviceId' as '${device.id}'", context = TAG)
setEffect(HwConnectEffect.NavigateToPaired)
}
+ private fun observeUnlocking() {
+ viewModelScope.launch {
+ hwWalletRepo.deviceState.map { it.isUnlocking }.distinctUntilChanged().collect { isUnlocking ->
+ _uiState.update { it.copy(isUnlocking = isUnlocking) }
+ }
+ }
+ }
+
private fun observePairingCode() {
viewModelScope.launch {
hwWalletRepo.pairingCodeRequestId.collect { requestId ->
@@ -404,6 +432,9 @@ data class HwConnectUiState(
val isSubmittingPassphrase: Boolean = false,
val deviceName: String = "",
val deviceModel: String = "",
+ val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
+ /** A Jade is waiting for its PIN on the device while connecting. */
+ val isUnlocking: Boolean = false,
val balanceSats: ULong = 0uL,
val labelInput: String = "",
val errorMessage: String? = null,
@@ -411,7 +442,11 @@ data class HwConnectUiState(
sealed interface HwConnectEffect {
data object NavigateToSearching : HwConnectEffect
- data class NavigateToFound(val deviceId: String, val deviceModel: String) : HwConnectEffect
+ data class NavigateToFound(
+ val deviceId: String,
+ val deviceModel: String,
+ val vendor: HwWalletVendor = HwWalletVendor.TREZOR,
+ ) : HwConnectEffect
data class NavigateToPairCode(val requestId: Long) : HwConnectEffect
data object NavigateToPaired : HwConnectEffect
data object NavigateToPassphrase : HwConnectEffect
diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwFoundSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwFoundSheet.kt
index 440c10ae6d..ab37c079ea 100644
--- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwFoundSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwFoundSheet.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import to.bitkit.R
+import to.bitkit.models.HwWalletVendor
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyS
import to.bitkit.ui.components.BottomSheetPreview
@@ -27,6 +28,8 @@ import to.bitkit.ui.components.Display
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.components.foundHeaderRes
+import to.bitkit.ui.components.illustrationRes
import to.bitkit.ui.scaffold.SheetTopBar
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.shared.util.gradientBackground
@@ -38,14 +41,18 @@ import to.bitkit.ui.utils.withAccent
fun HwFoundSheet(
deviceModel: String,
modifier: Modifier = Modifier,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
isConnecting: Boolean = false,
+ isUnlocking: Boolean = false,
errorMessage: String? = null,
onConnect: () -> Unit = {},
onCancel: () -> Unit = {},
) {
Content(
deviceModel = deviceModel,
+ vendor = vendor,
isConnecting = isConnecting,
+ isUnlocking = isUnlocking,
errorMessage = errorMessage,
onConnect = onConnect,
onCancel = onCancel,
@@ -57,7 +64,9 @@ fun HwFoundSheet(
private fun Content(
deviceModel: String,
modifier: Modifier = Modifier,
+ vendor: HwWalletVendor = HwWalletVendor.TREZOR,
isConnecting: Boolean = false,
+ isUnlocking: Boolean = false,
errorMessage: String? = null,
onConnect: () -> Unit = {},
onCancel: () -> Unit = {},
@@ -75,9 +84,19 @@ private fun Content(
.fillMaxWidth()
.padding(horizontal = 32.dp)
) {
- Display(stringResource(R.string.hardware__found_header).withAccent(accentColor = Colors.Blue))
+ Display(stringResource(vendor.foundHeaderRes()).withAccent(accentColor = Colors.Blue))
VerticalSpacer(8.dp)
BodyM(stringResource(R.string.hardware__found_text, deviceModel), color = Colors.White64)
+ AnimatedVisibility(visible = isUnlocking) {
+ Column {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.hardware__jade_enter_pin),
+ color = Colors.White,
+ modifier = Modifier.testTag("HwFoundUnlockHint")
+ )
+ }
+ }
AnimatedVisibility(visible = errorMessage != null) {
Column {
VerticalSpacer(16.dp)
@@ -96,7 +115,7 @@ private fun Content(
.weight(1f)
) {
Image(
- painter = painterResource(R.drawable.trezor),
+ painter = painterResource(vendor.illustrationRes()),
contentDescription = null,
modifier = Modifier.size(256.dp)
)
@@ -140,3 +159,19 @@ private fun Preview() {
}
}
}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewJadeUnlocking() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ Content(
+ deviceModel = "Jade",
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ isConnecting = true,
+ isUnlocking = true,
+ modifier = Modifier.sheetHeight()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt
index 2642370e44..96b5de79d3 100644
--- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt
@@ -26,6 +26,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import to.bitkit.R
+import to.bitkit.models.HwWalletVendor
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BottomSheetPreview
import to.bitkit.ui.components.Caption13Up
@@ -36,6 +37,7 @@ import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.TextInput
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.components.WalletBalanceView
+import to.bitkit.ui.components.pairedHeaderRes
import to.bitkit.ui.scaffold.SheetTopBar
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.shared.util.gradientBackground
@@ -56,7 +58,7 @@ fun HwPairedSheet(
) {
HwPairedContent(
uiState = uiState,
- header = stringResource(R.string.hardware__paired_header).withAccent(accentColor = Colors.Blue),
+ header = stringResource(uiState.vendor.pairedHeaderRes()).withAccent(accentColor = Colors.Blue),
text = stringResource(R.string.hardware__paired_text),
screenTag = "HardwareWalletPairedScreen",
onLabelChange = onLabelChange,
@@ -140,6 +142,8 @@ internal fun HwPairedContent(
)
HwPairedButtons(
hazeState = hazeState,
+ // Passphrase (hidden) wallets are a Trezor feature; a Jade has one wallet per device.
+ showPassphrase = uiState.vendor == HwWalletVendor.TREZOR,
onPassphrase = onPassphrase,
onFinish = onFinish,
modifier = Modifier
@@ -154,6 +158,7 @@ internal fun HwPairedContent(
private fun HwPairedButtons(
hazeState: HazeState,
modifier: Modifier = Modifier,
+ showPassphrase: Boolean = true,
onPassphrase: () -> Unit = {},
onFinish: () -> Unit = {},
) {
@@ -161,14 +166,16 @@ private fun HwPairedButtons(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxWidth()
) {
- SecondaryButton(
- text = stringResource(R.string.hardware__passphrase_button),
- onClick = onPassphrase,
- hazeState = hazeState,
- modifier = Modifier
- .weight(1f)
- .testTag("HardwareWalletPairedPassphrase")
- )
+ if (showPassphrase) {
+ SecondaryButton(
+ text = stringResource(R.string.hardware__passphrase_button),
+ onClick = onPassphrase,
+ hazeState = hazeState,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("HardwareWalletPairedPassphrase")
+ )
+ }
PrimaryButton(
text = stringResource(R.string.hardware__paired_finish),
onClick = onFinish,
@@ -196,6 +203,24 @@ private fun Preview() {
}
}
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewJade() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ HwPairedSheet(
+ uiState = HwConnectUiState(
+ deviceName = "Jade",
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ balanceSats = 10_562_411uL,
+ labelInput = "Jade",
+ ),
+ modifier = Modifier.sheetHeight()
+ )
+ }
+ }
+}
+
@Preview(showSystemUi = true)
@Composable
private fun PreviewEmpty() {
diff --git a/app/src/main/java/to/bitkit/ui/utils/HwUsbId.kt b/app/src/main/java/to/bitkit/ui/utils/HwUsbId.kt
new file mode 100644
index 0000000000..2af8fea200
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/utils/HwUsbId.kt
@@ -0,0 +1,62 @@
+package to.bitkit.ui.utils
+
+import android.hardware.usb.UsbDevice
+import to.bitkit.models.HwWalletVendor
+
+/** USB identities of the hardware wallets Bitkit recognises, mirrored by `res/xml/usb_device_filter.xml`. */
+enum class HwUsbId(
+ val vendorId: Int,
+ val productId: Int,
+ val vendor: HwWalletVendor,
+ val isBootloader: Boolean = false,
+) {
+ TREZOR_WEBUSB(
+ vendorId = 0x1209,
+ productId = 0x53C1,
+ vendor = HwWalletVendor.TREZOR,
+ ),
+ TREZOR_WEBUSB_BOOTLOADER(
+ vendorId = 0x1209,
+ productId = 0x53C0,
+ vendor = HwWalletVendor.TREZOR,
+ isBootloader = true,
+ ),
+ TREZOR_LEGACY(
+ vendorId = 0x534C,
+ productId = 0x0001,
+ vendor = HwWalletVendor.TREZOR,
+ ),
+
+ /** Jade v1: Silicon Labs CP210x USB-serial bridge. */
+ JADE_CP210X(
+ vendorId = 0x10C4,
+ productId = 0xEA60,
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ ),
+
+ /** Jade Plus: Espressif native USB CDC. */
+ JADE_ESPRESSIF_CDC(
+ vendorId = 0x303A,
+ productId = 0x4001,
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ ),
+
+ /** Jade Plus: Espressif USB serial/JTAG. */
+ JADE_ESPRESSIF_SERIAL_JTAG(
+ vendorId = 0x303A,
+ productId = 0x1001,
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ ),
+ ;
+
+ companion object {
+ fun of(vendorId: Int, productId: Int): HwUsbId? =
+ entries.firstOrNull { it.vendorId == vendorId && it.productId == productId }
+ }
+}
+
+fun UsbDevice.hwUsbId(): HwUsbId? = HwUsbId.of(vendorId, productId)
+
+fun UsbDevice.hwVendorOrNull(): HwWalletVendor? = hwUsbId()?.vendor
+
+fun UsbDevice.isHwBootloader(): Boolean = hwUsbId()?.isBootloader == true
diff --git a/app/src/main/java/to/bitkit/utils/HwErrorPresenter.kt b/app/src/main/java/to/bitkit/utils/HwErrorPresenter.kt
new file mode 100644
index 0000000000..3ffa221677
--- /dev/null
+++ b/app/src/main/java/to/bitkit/utils/HwErrorPresenter.kt
@@ -0,0 +1,32 @@
+package to.bitkit.utils
+
+import android.content.Context
+import com.synonym.bitkitcore.JadeException
+import to.bitkit.R
+
+/** User-facing messages for hardware wallet errors of every vendor; Jade first, then the Trezor rules. */
+object HwErrorPresenter {
+ fun userMessage(context: Context, error: Throwable): String =
+ jadeMessage(context, error) ?: TrezorErrorPresenter.userMessage(context, error)
+
+ fun userMessage(context: Context, error: Throwable, fallback: String): String =
+ jadeMessage(context, error) ?: TrezorErrorPresenter.userMessage(context, error, fallback)
+
+ private fun jadeMessage(context: Context, error: Throwable): String? {
+ val jadeError = generateSequence(error) { it.cause }.firstOrNull { it is JadeException } ?: return null
+ val res = when (jadeError) {
+ is JadeException.InvalidPin -> R.string.hardware__jade_invalid_pin
+ is JadeException.DeviceUninitialized -> R.string.hardware__jade_uninitialized
+ is JadeException.UnsupportedFirmware -> R.string.hardware__jade_firmware_outdated
+ is JadeException.PsbtTooLarge -> R.string.hardware__jade_psbt_too_large
+ is JadeException.NetworkMismatch -> R.string.hardware__jade_network_mismatch
+ is JadeException.DeviceBusy, is JadeException.DeviceLocked -> R.string.hardware__jade_device_busy
+ is JadeException.PinServerException -> R.string.hardware__jade_pinserver_error
+ // The transport describes what went wrong in words meant for the user.
+ is JadeException.TransportException -> return jadeError.errorDetails.takeIf { it.isNotBlank() }
+ is JadeException.ConnectionException -> return jadeError.errorDetails.takeIf { it.isNotBlank() }
+ else -> return null
+ }
+ return context.getString(res)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index dae74658dc..397551ec3b 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -92,7 +92,7 @@ import to.bitkit.ext.claimableAtHeight
import to.bitkit.ext.getClipboardText
import to.bitkit.ext.getSatsPerVByteFor
import to.bitkit.ext.isFixedAmount
-import to.bitkit.ext.isTrezorUserCancellation
+import to.bitkit.ext.isHwUserCancellation
import to.bitkit.ext.maxSendableSat
import to.bitkit.ext.maxWithdrawableSat
import to.bitkit.ext.minSendableSat
@@ -110,6 +110,7 @@ import to.bitkit.ext.walletId
import to.bitkit.ext.watchUntil
import to.bitkit.flags.PaykitFeatureFlags
import to.bitkit.models.FeeRate
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
@@ -1862,11 +1863,19 @@ class AppViewModel @Inject constructor(
private fun showHardwareOnchainOnlyValidationError() {
showAddressValidationError(
titleRes = R.string.hardware__send_onchain_only_title,
- descriptionRes = R.string.hardware__send_onchain_only_text,
+ descriptionRes = hardwareOnchainOnlyTextRes(),
testTag = "HardwareOnchainOnlyToast",
)
}
+ private fun hardwareOnchainOnlyTextRes(): Int {
+ val vendor = hwWalletRepo.wallets.value.find { it.id == activeHardwareWalletId }?.vendor
+ return when (vendor) {
+ HwWalletVendor.BLOCKSTREAM -> R.string.hardware__send_onchain_only_text_jade
+ else -> R.string.hardware__send_onchain_only_text
+ }
+ }
+
private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? =
params?.get("lightning")?.let { bolt11 ->
runSuspendCatching { coreService.decode(bolt11) }.getOrNull()
@@ -2551,7 +2560,7 @@ class AppViewModel @Inject constructor(
toast(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.hardware__send_onchain_only_title),
- description = context.getString(R.string.hardware__send_onchain_only_text),
+ description = context.getString(hardwareOnchainOnlyTextRes()),
)
clearActiveContactPaymentContext()
return
@@ -4193,7 +4202,7 @@ class AppViewModel @Inject constructor(
}
fun toast(error: Throwable) {
- if (error.isTrezorUserCancellation()) return
+ if (error.isHwUserCancellation()) return
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
@@ -4524,12 +4533,13 @@ class AppViewModel @Inject constructor(
fun onUsbDeviceAttached(
deviceId: String? = null,
deviceModel: String = "",
+ vendor: HwWalletVendor? = null,
) {
- hwWalletRepo.onTransportRestored(TransportType.USB)
+ hwWalletRepo.onTransportRestored(TransportType.USB, vendor)
deviceId ?: return
viewModelScope.launch {
- if (hwWalletRepo.hasKnownDevice(deviceId)) return@launch
+ if (hwWalletRepo.hasKnownDevice(deviceId, vendor)) return@launch
if (isHighPrioritySheet(_currentSheet.value)) return@launch
if (_currentSheet.value is Sheet.Hardware) return@launch
@@ -4538,6 +4548,7 @@ class AppViewModel @Inject constructor(
route = HardwareRoute.Found(
deviceId = deviceId,
deviceModel = deviceModel,
+ vendor = vendor ?: HwWalletVendor.TREZOR,
),
)
)
diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
index a993616619..94f3a8c278 100644
--- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
@@ -10,6 +10,7 @@ import com.synonym.bitkitcore.BtOrderState2
import com.synonym.bitkitcore.IBtOrder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
@@ -46,15 +47,16 @@ 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.isTrezorSessionFailure
-import to.bitkit.ext.isTrezorUserCancellation
+import to.bitkit.ext.isHwDeviceBusy
+import to.bitkit.ext.isHwFirmwareError
+import to.bitkit.ext.isHwSessionFailure
+import to.bitkit.ext.isHwUserCancellation
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toUserMessage
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
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransferType
@@ -70,6 +72,7 @@ import to.bitkit.repositories.WalletRepo
import to.bitkit.services.BoltzService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.AppError
+import to.bitkit.utils.HwErrorPresenter
import to.bitkit.utils.Logger
import javax.inject.Inject
import kotlin.math.min
@@ -97,6 +100,10 @@ class TransferViewModel @Inject constructor(
private val boltzService: BoltzService,
private val clock: Clock,
) : ViewModel() {
+
+ /** Paired hardware wallets, for screens that render vendor-specific device visuals. */
+ val hardwareWallets: StateFlow>
+ get() = hwWalletRepo.wallets
private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState())
val spendingUiState = _spendingUiState.asStateFlow()
@@ -1169,12 +1176,13 @@ class TransferViewModel @Inject constructor(
@Suppress("ThrowsCount")
private suspend fun ensureHardwareConnected(walletId: String) {
runCatching {
- withTimeout(HW_RECONNECT_TIMEOUT) {
+ // A Jade reconnect may include entering the PIN on the device, so the budget is per vendor.
+ withTimeout(hwWalletRepo.reconnectTimeout(walletId)) {
hwWalletRepo.ensureConnected(walletId).getOrThrow()
}
}.getOrElse {
it.rethrowIfCancellation()
- if (it.isTrezorUserCancellation()) throw it
+ if (it.isHwUserCancellation()) throw it
throw HardwareReconnectError(it)
}
}
@@ -1204,7 +1212,7 @@ class TransferViewModel @Inject constructor(
): HwFundingSignedTx {
val firstAttempt = runSuspendCatching { signHardwareFundingOnce(walletId, funding) }
val error = firstAttempt.exceptionOrNull() ?: return firstAttempt.getOrThrow()
- if (!error.isTrezorSessionFailure()) throw error
+ if (!error.isHwSessionFailure()) throw error
ensureHardwareConnected(walletId)
return signHardwareFundingOnce(walletId, funding)
@@ -1244,7 +1252,7 @@ class TransferViewModel @Inject constructor(
}
private suspend fun handleHardwareTransferFailure(e: Throwable, walletId: String) {
- if (e.isTrezorUserCancellation()) {
+ if (e.isHwUserCancellation()) {
Logger.info("Hardware transfer cancelled on device for '$walletId'", context = TAG)
return
}
@@ -1254,16 +1262,16 @@ class TransferViewModel @Inject constructor(
_spendingUiState.update { it.copy(isHwPassphraseRequired = true) }
return
}
- if (e.isTrezorDeviceBusy()) {
- Logger.warn("Blocked hardware transfer for locked or busy Trezor '$walletId'", e, context = TAG)
+ if (e.isHwDeviceBusy()) {
+ Logger.warn("Blocked hardware transfer for locked or busy device '$walletId'", e, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.INFO,
- title = context.getString(R.string.hardware__device_busy),
+ title = HwErrorPresenter.userMessage(context, e),
)
return
}
- if (e.isTrezorFirmwareError()) {
- Logger.warn("Received Trezor firmware error for '$walletId'", e, context = TAG)
+ if (e.isHwFirmwareError()) {
+ Logger.warn("Received hardware firmware error for '$walletId'", e, context = TAG)
showHardwareReconnectRequiredError()
return
}
@@ -1834,9 +1842,6 @@ class TransferViewModel @Inject constructor(
/** Minimum fallback fee rate when fee estimates are temporarily unavailable. */
private const val HW_FUNDING_FALLBACK_SATS_PER_VBYTE = 3uL
- /** Upper bound for reconnecting a known device before the UI asks for reconnect. */
- private val HW_RECONNECT_TIMEOUT = 30.seconds
-
/** Upper bound for exact hardware funding composition before signing starts. */
private val HW_COMPOSE_TIMEOUT = 45.seconds
diff --git a/app/src/main/res/drawable/jade_placeholder.xml b/app/src/main/res/drawable/jade_placeholder.xml
new file mode 100644
index 0000000000..f28e50dabb
--- /dev/null
+++ b/app/src/main/res/drawable/jade_placeholder.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 30c8a6ee5c..eb9003cfaf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -178,15 +178,26 @@
Disconnected via Bluetooth
Disconnected via USB
Your Trezor is busy. Unlock it on the device, then try again.
+ Jade
Trezor
Found <accent>Trezor</accent>
+ Found <accent>Jade</accent>
Would you like to securely pair this %1$s with Bitkit?
Found Device
Add your <accent>hardware wallet</accent>
Connect your hardware device to watch or manage your long-term funds.
Hardware Wallet
+ Your Jade is busy. Finish what is shown on the device, then try again.
+ Enter your PIN on your Jade to unlock it.
+ Your Jade firmware is too old for this action. Update it with the Blockstream app, then try again.
+ Wrong PIN. Try again on your Jade.
+ Your Jade is set up for a different Bitcoin network.
+ Could not reach the Jade PIN server. Check your internet connection and try again.
+ This transaction is too large for Jade to sign. Try sending a smaller amount.
+ This Jade has not been set up yet. Create or restore a wallet on the device, then try again.
Finish
Paired <accent>Trezor</accent>
+ Paired <accent>Jade</accent>
Label Funds
Bitkit found funds on your device and added these to your balance.
Device Connected
@@ -198,13 +209,14 @@
Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again.
Enter <accent>passphrase</accent>
That passphrase opens a different wallet. Enter the one you paired this wallet with.
- Enter the passphrase of this wallet so your hardware device can sign the transaction.
Passphrase <accent>funds found</accent>
Bitkit found funds behind a passphrase, and added these to your wallet balance.
+ Enter the passphrase of this wallet so your hardware device can sign the transaction.
If you have funds protected by a passphrase, enter it below to add these funds to your wallet balance as well.
Passphrase
Enter the passphrase of this wallet so your hardware device can display the receive address for verification.
Could not load the hardware wallet address.
+ Hardware
Remove %1$s
Keep name and tags in backup
Don\'t worry, your funds are safe and your coins won\'t be deleted. Bitkit will simply stop displaying the amounts in the wallet.
@@ -215,8 +227,10 @@
Could not search for hardware wallets. Check your connection and try again.
TO ADDRESS (CONFIRM ON DEVICE)
Trezor can only send to a Bitcoin address from this wallet.
+ Jade can only send to a Bitcoin address from this wallet.
Bitcoin address required
Open Trezor Connect
+ Sign With Jade
Sign With Device
Verify on Device
Address verification failed. Check the address on your device and try again.
diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml
index ace761801c..9c0348e226 100644
--- a/app/src/main/res/xml/usb_device_filter.xml
+++ b/app/src/main/res/xml/usb_device_filter.xml
@@ -6,4 +6,10 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/to/bitkit/ext/HwExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/HwExceptionExtTest.kt
new file mode 100644
index 0000000000..51a697499e
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ext/HwExceptionExtTest.kt
@@ -0,0 +1,43 @@
+package to.bitkit.ext
+
+import com.synonym.bitkitcore.JadeException
+import com.synonym.bitkitcore.TrezorException
+import org.junit.Test
+import to.bitkit.utils.AppError
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class HwExceptionExtTest {
+
+ @Test
+ fun `jade user cancellation is detected through the cause chain`() {
+ assertTrue(AppError(JadeException.UserCancelled()).isJadeUserCancellation())
+ assertTrue(AppError(JadeException.UserCancelled()).isHwUserCancellation())
+ assertFalse(AppError(JadeException.DeviceBusy()).isJadeUserCancellation())
+ }
+
+ @Test
+ fun `a locked or busy jade counts as busy`() {
+ assertTrue(JadeException.DeviceLocked().isJadeDeviceBusy())
+ assertTrue(JadeException.DeviceBusy().isHwDeviceBusy())
+ assertTrue(TrezorException.DeviceBusy().isHwDeviceBusy())
+ assertFalse(JadeException.Timeout().isHwDeviceBusy())
+ }
+
+ @Test
+ fun `outdated jade firmware is a firmware error`() {
+ assertTrue(JadeException.UnsupportedFirmware("0.1.0", "1.0.34").isJadeFirmwareError())
+ assertTrue(AppError(JadeException.UnsupportedFirmware("0.1.0", "1.0.34")).isHwFirmwareError())
+ assertFalse(JadeException.InvalidPin().isHwFirmwareError())
+ }
+
+ @Test
+ fun `transport level jade failures are session failures`() {
+ assertTrue(JadeException.DeviceDisconnected().isJadeSessionFailure())
+ assertTrue(JadeException.Timeout().isHwSessionFailure())
+ assertTrue(AppError(JadeException.TransportException("usb")).isHwSessionFailure())
+ assertTrue(TrezorException.DeviceDisconnected().isHwSessionFailure())
+ assertFalse(JadeException.InvalidPin().isHwSessionFailure())
+ assertFalse(JadeException.AddressMismatch("a", "b").isHwSessionFailure())
+ }
+}
diff --git a/app/src/test/java/to/bitkit/models/KnownDeviceTest.kt b/app/src/test/java/to/bitkit/models/KnownDeviceTest.kt
new file mode 100644
index 0000000000..3d8ad54fc6
--- /dev/null
+++ b/app/src/test/java/to/bitkit/models/KnownDeviceTest.kt
@@ -0,0 +1,80 @@
+package to.bitkit.models
+
+import org.junit.Test
+import to.bitkit.di.json
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class KnownDeviceTest {
+
+ @Test
+ fun `entries stored before jade existed decode as trezor devices`() {
+ val stored = """
+ {"id":"dev1","name":null,"path":"ble:AA","transportType":"bluetooth","label":"Trezor",
+ "model":"Safe 5","lastConnectedAt":0,"xpubs":{"nativeSegwit":"zpub"},"walletId":"w1"}
+ """.trimIndent()
+
+ val device = json.decodeFromString(stored)
+
+ assertEquals(HwWalletVendor.TREZOR, device.vendor)
+ assertNull(device.jadeDeviceId)
+ assertNull(device.hardwareId)
+ }
+
+ @Test
+ fun `a jade entry round trips its vendor and hardware id`() {
+ val device = KnownDevice(
+ id = "jade:serial:aabbcc",
+ name = "Jade",
+ path = "/dev/bus/usb/001/004",
+ transportType = TransportType.USB,
+ label = null,
+ model = "Jade",
+ lastConnectedAt = 1L,
+ xpubs = mapOf("nativeSegwit" to "zpub"),
+ walletId = "jade:abc",
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ jadeDeviceId = "aabbcc",
+ )
+
+ val decoded = json.decodeFromString(json.encodeToString(device))
+
+ assertEquals(device, decoded)
+ assertEquals("aabbcc", decoded.hardwareId)
+ }
+
+ @Test
+ fun `a jade entry is replaced by a re-read of the same hardware even when its keys changed`() {
+ val stored = KnownDevice(
+ id = "jade:serial:aabbcc",
+ name = null,
+ path = "/dev/bus/usb/001/004",
+ transportType = TransportType.USB,
+ label = null,
+ model = "Jade",
+ lastConnectedAt = 1L,
+ xpubs = mapOf("nativeSegwit" to "zpub"),
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ jadeDeviceId = "aabbcc",
+ )
+ val reread = stored.copy(
+ path = "/dev/bus/usb/001/009",
+ xpubs = mapOf("nativeSegwit" to "zpub", "taproot" to "tr")
+ )
+ val otherJade = stored.copy(xpubs = mapOf("nativeSegwit" to "other"), jadeDeviceId = "ddeeff")
+
+ assertTrue(stored.isReplacedBy(reread, refreshed = stored))
+ assertTrue(stored.isReplacedBy(otherJade, refreshed = null))
+ }
+
+ @Test
+ fun `wallet ids are derived in the vendor namespace`() {
+ val xpubs = mapOf("nativeSegwit" to "zpub")
+
+ val jade = runCatching { deriveHardwareWalletId(xpubs, HwWalletVendor.BLOCKSTREAM) }.getOrNull()
+
+ // The native derivation is unavailable in unit tests; the call must not throw either way.
+ assertTrue(jade == null || jade.startsWith("jade:"))
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt
index 5e31cda7f8..31011d4a1a 100644
--- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt
@@ -76,6 +76,7 @@ class HwWalletRepoTest : BaseUnitTest() {
}
private val trezorRepo = mock()
+ private val jadeRepo = mock()
private val activityRepo = mock()
private val preActivityMetadataRepo = mock()
private val hwWalletStore = mock()
@@ -84,6 +85,7 @@ class HwWalletRepoTest : BaseUnitTest() {
private lateinit var storeData: MutableStateFlow
private lateinit var settingsData: MutableStateFlow
private lateinit var trezorState: MutableStateFlow
+ private lateinit var jadeState: MutableStateFlow
private lateinit var watcherEvents: MutableSharedFlow>
private val device = KnownDevice(
@@ -110,7 +112,10 @@ class HwWalletRepoTest : BaseUnitTest() {
storeData = MutableStateFlow(HwWalletData(knownDevices = listOf(device)))
settingsData = MutableStateFlow(SettingsData())
trezorState = MutableStateFlow(TrezorState())
+ jadeState = MutableStateFlow(JadeRepoState())
watcherEvents = MutableSharedFlow(extraBufferCapacity = 8)
+ whenever(jadeRepo.state).thenReturn(jadeState)
+ whenever { jadeRepo.scan(any()) }.thenReturn(Result.success(emptyList()))
whenever(hwWalletStore.data).thenReturn(storeData)
whenever(settingsStore.data).thenReturn(settingsData)
whenever(trezorRepo.state).thenReturn(trezorState)
@@ -140,6 +145,7 @@ class HwWalletRepoTest : BaseUnitTest() {
private fun createRepo() = HwWalletRepo(
trezorRepo = trezorRepo,
+ jadeRepo = jadeRepo,
activityRepo = activityRepo,
preActivityMetadataRepo = preActivityMetadataRepo,
hwWalletStore = hwWalletStore,
@@ -1923,6 +1929,7 @@ class HwWalletRepoTest : BaseUnitTest() {
network = any(),
accountType = anyOrNull(),
coinSelection = any(),
+ fingerprint = anyOrNull(),
)
).thenReturn(
Result.success(
diff --git a/app/src/test/java/to/bitkit/repositories/JadeRepoTest.kt b/app/src/test/java/to/bitkit/repositories/JadeRepoTest.kt
new file mode 100644
index 0000000000..d8b3e84771
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/JadeRepoTest.kt
@@ -0,0 +1,400 @@
+package to.bitkit.repositories
+
+import android.content.Context
+import com.synonym.bitkitcore.AccountType
+import com.synonym.bitkitcore.CompletedTransaction
+import com.synonym.bitkitcore.JadeAccount
+import com.synonym.bitkitcore.JadeAccountExport
+import com.synonym.bitkitcore.JadeAddressVariant
+import com.synonym.bitkitcore.JadeDeviceInfo
+import com.synonym.bitkitcore.JadeException
+import com.synonym.bitkitcore.JadeNetwork
+import com.synonym.bitkitcore.JadeState
+import com.synonym.bitkitcore.JadeTransportKind
+import com.synonym.bitkitcore.JadeVersionInfo
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.data.HwWalletStore
+import to.bitkit.models.HwFundingAddressType
+import to.bitkit.models.HwWalletVendor
+import to.bitkit.models.KnownDevice
+import to.bitkit.models.TransportType
+import to.bitkit.services.JadeService
+import to.bitkit.services.JadeTransport
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.ExperimentalTime
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
+class JadeRepoTest : BaseUnitTest() {
+
+ private val context = mock()
+ private val jadeService = mock()
+ private val jadeTransport = mock()
+ private val hwWalletStore = mock()
+ private val externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1)
+ private val transportRestored = MutableSharedFlow(extraBufferCapacity = 1)
+
+ private val usbDevice = JadeDeviceInfo(
+ path = USB_PATH,
+ transport = JadeTransportKind.SERIAL,
+ name = "Jade",
+ serialNumber = null,
+ )
+
+ private val knownUsb = KnownDevice(
+ id = "jade:serial:$EFUSE_MAC",
+ name = "Jade",
+ path = "/dev/bus/usb/001/002",
+ transportType = TransportType.USB,
+ label = null,
+ model = "Jade",
+ lastConnectedAt = 0L,
+ xpubs = mapOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey to "zpubNS"),
+ walletId = WALLET_ID,
+ vendor = HwWalletVendor.BLOCKSTREAM,
+ jadeDeviceId = EFUSE_MAC,
+ )
+
+ @Before
+ fun setUp() {
+ whenever(jadeTransport.externalDisconnect).thenReturn(externalDisconnect)
+ whenever(jadeTransport.transportRestored).thenReturn(transportRestored)
+ whenever(jadeTransport.hasUsbPermission(any())).thenReturn(true)
+ whenever(jadeTransport.disconnectDevice(any())).thenReturn(mock())
+ whenever(context.getString(any())).thenReturn("message")
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(emptyList())
+ whenever { hwWalletStore.loadPendingNames() }.thenReturn(emptyMap())
+ whenever { jadeService.isConnected() }.thenReturn(false)
+ whenever { jadeService.listDevices() }.thenReturn(emptyList())
+ whenever { jadeService.getAccountExport(any(), any(), any()) }.thenReturn(accountExport())
+ whenever { jadeService.refreshVersionInfo() }.thenReturn(versionInfo(JadeState.READY))
+ }
+
+ @Test
+ fun `scan reuses the last device list while a session is open`() = test {
+ whenever { jadeService.isConnected() }.thenReturn(true)
+ whenever { jadeService.listDevices() }.thenReturn(listOf(usbDevice))
+ val sut = createRepo()
+
+ val result = sut.scan()
+
+ assertEquals(listOf(usbDevice), result.getOrThrow())
+ verify(jadeService, never()).scan(any(), any())
+ assertEquals(listOf(usbDevice), sut.state.value.nearbyDevices)
+ }
+
+ @Test
+ fun `connect unlocks a locked jade then reads its accounts and stores the entry`() = test {
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.LOCKED))
+ val sut = createRepo()
+ sut.scan()
+
+ val result = sut.connect(USB_PATH)
+
+ val connected = result.getOrThrow()
+ verify(jadeService).unlock(JadeNetwork.REGTEST)
+ verify(jadeService).getAccountExport(eq(JadeNetwork.REGTEST), eq(ALL_ACCOUNT_TYPES), any())
+ val captor = argumentCaptor>()
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.BLOCKSTREAM))
+ val stored = captor.firstValue.single()
+ assertEquals("jade:serial:$EFUSE_MAC", stored.id)
+ assertEquals(HwWalletVendor.BLOCKSTREAM, stored.vendor)
+ assertEquals(EFUSE_MAC, stored.jadeDeviceId)
+ assertEquals(USB_PATH, stored.path)
+ assertEquals("zpubNS", stored.xpubs[HwFundingAddressType.NATIVE_SEGWIT.settingsKey])
+ assertEquals("Jade", stored.model)
+ assertEquals(stored.id, connected.id)
+ assertFalse(connected.isLocked)
+ assertTrue(sut.state.value.nearbyDevices.isEmpty())
+ }
+
+ @Test
+ fun `connect refuses a jade that has no wallet yet`() = test {
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.UNINIT))
+ val sut = createRepo()
+ sut.scan()
+
+ val result = sut.connect(USB_PATH)
+
+ assertTrue(result.exceptionOrNull() is HwDeviceUninitializedError)
+ verify(jadeService).disconnect()
+ verify(jadeService, never()).unlock(any())
+ assertNull(sut.state.value.connected)
+ }
+
+ @Test
+ fun `connect reads the accounts again without taproot on old firmware`() = test {
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ whenever { jadeService.getAccountExport(eq(JadeNetwork.REGTEST), eq(ALL_ACCOUNT_TYPES), any()) }
+ .thenAnswer { throw JadeException.UnsupportedFirmware(installed = "1.0.30", required = "1.0.34") }
+ whenever { jadeService.getAccountExport(eq(JadeNetwork.REGTEST), eq(WITHOUT_TAPROOT), any()) }
+ .thenReturn(accountExport())
+ val sut = createRepo()
+ sut.scan()
+
+ val result = sut.connect(USB_PATH)
+
+ assertTrue(result.isSuccess, "err=${result.exceptionOrNull()}")
+ verify(
+ jadeService
+ ).getAccountExport(eq(JadeNetwork.REGTEST), eq(ALL_ACCOUNT_TYPES - AccountType.TAPROOT), any())
+ }
+
+ @Test
+ fun `a replugged jade refreshes its stored entry instead of adding one`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+ sut.scan()
+
+ sut.connect(USB_PATH).getOrThrow()
+
+ val captor = argumentCaptor>()
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.BLOCKSTREAM))
+ val stored = captor.firstValue.single()
+ assertEquals(knownUsb.id, stored.id)
+ assertEquals(USB_PATH, stored.path)
+ assertEquals(WALLET_ID, stored.walletId)
+ }
+
+ @Test
+ fun `reconnecting a known jade rejects a different device`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }
+ .thenReturn(versionInfo(JadeState.READY, efuseMac = "other"))
+ val sut = createRepo()
+
+ val result = sut.connectKnownDevice(knownUsb.id)
+
+ assertTrue(result.isFailure)
+ verify(jadeService, atLeastOnce()).disconnect()
+ verify(jadeService, never()).getAccountExport(any(), any(), any())
+ assertNull(sut.state.value.connected)
+ }
+
+ @Test
+ fun `a known bluetooth jade is recognised by name after its address changed`() = test {
+ val knownBle = knownUsb.copy(
+ id = "jade:bluetooth:$EFUSE_MAC",
+ path = "ble:6B:7A:9B:16:C8:1C",
+ transportType = TransportType.BLUETOOTH,
+ )
+ val readvertised = JadeDeviceInfo(
+ path = "ble:56:C4:BF:B3:9E:75",
+ transport = JadeTransportKind.BLUETOOTH,
+ name = "Jade 8F6B64",
+ serialNumber = null,
+ )
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownBle))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(readvertised))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+
+ val connected = sut.connectKnownDevice(knownBle.id).getOrThrow()
+
+ assertEquals(readvertised.path, connected.path)
+ verify(jadeService).connect(eq(JadeTransportKind.BLUETOOTH), eq(readvertised.path), any())
+ val nearby = sut.scan().getOrThrow()
+ assertEquals(listOf(readvertised), nearby)
+ assertTrue(sut.state.value.nearbyDevices.isEmpty(), "a paired jade is not offered as new")
+ }
+
+ @Test
+ fun `silent auto reconnect never asks for the pin`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.LOCKED))
+ val sut = createRepo()
+
+ val result = sut.autoReconnect(preferredTransport = TransportType.USB)
+
+ val connected = result.getOrThrow()
+ assertTrue(connected.isLocked)
+ assertEquals(WALLET_ID, connected.walletId)
+ verify(jadeService, never()).unlock(any())
+ verify(jadeService, never()).getAccountExport(any(), any(), any())
+ }
+
+ @Test
+ fun `ensureConnected unlocks a locked session`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.LOCKED))
+ val sut = createRepo()
+ sut.autoReconnect(preferredTransport = TransportType.USB).getOrThrow()
+ whenever { jadeService.isConnected() }.thenReturn(true)
+
+ val result = sut.ensureConnected(knownUsb.id)
+
+ assertFalse(result.getOrThrow().isLocked)
+ verify(jadeService).unlock(JadeNetwork.REGTEST)
+ // The live session is reused: no second connect.
+ verify(jadeService).connect(any(), any(), any())
+ }
+
+ @Test
+ fun `a bluetooth link is released after the app stays in the background`() = test {
+ val knownBle = knownUsb.copy(
+ id = "jade:bluetooth:$EFUSE_MAC",
+ path = "ble:56:C4:BF:B3:9E:75",
+ transportType = TransportType.BLUETOOTH,
+ )
+ val bleDevice = JadeDeviceInfo(knownBle.path, JadeTransportKind.BLUETOOTH, "Jade 8F6B64", null)
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownBle))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(bleDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+ sut.connectKnownDevice(knownBle.id).getOrThrow()
+
+ sut.onAppBackgrounded()
+ advanceTimeBy(10.seconds)
+ sut.onAppForegrounded()
+ advanceTimeBy(60.seconds)
+ verify(jadeService, never()).disconnect()
+
+ sut.onAppBackgrounded()
+ advanceTimeBy(31.seconds)
+
+ verify(jadeService).disconnect()
+ assertNull(sut.state.value.connected)
+ }
+
+ @Test
+ fun `a usb link is kept while the app is in the background`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+ sut.connectKnownDevice(knownUsb.id).getOrThrow()
+
+ sut.onAppBackgrounded()
+ advanceTimeBy(60.seconds)
+
+ verify(jadeService, never()).disconnect()
+ }
+
+ @Test
+ fun `an external disconnect clears the session and tells core`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+ sut.connectKnownDevice(knownUsb.id).getOrThrow()
+
+ externalDisconnect.emit(USB_PATH)
+ advanceUntilIdle()
+
+ assertNull(sut.state.value.connected)
+ verify(jadeService).notifyDisconnected(USB_PATH)
+ }
+
+ @Test
+ fun `forgetting the connected jade closes its session and drops the entry`() = test {
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.BLOCKSTREAM) }.thenReturn(listOf(knownUsb))
+ whenever { jadeService.scan(any(), any()) }.thenReturn(listOf(usbDevice))
+ whenever { jadeService.connect(any(), any(), any()) }.thenReturn(versionInfo(JadeState.READY))
+ val sut = createRepo()
+ sut.connectKnownDevice(knownUsb.id).getOrThrow()
+
+ val result = sut.forgetDevice(knownUsb.id)
+
+ assertTrue(result.isSuccess, "err=${result.exceptionOrNull()}")
+ verify(jadeService).disconnect()
+ val captor = argumentCaptor>()
+ verify(hwWalletStore, atLeastOnce())
+ .saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.BLOCKSTREAM))
+ assertTrue(captor.lastValue.isEmpty())
+ assertNull(sut.state.value.connected)
+ }
+
+ @Test
+ fun `signPsbt completes the signed psbt into a transaction`() = test {
+ val completed = CompletedTransaction(serializedTx = "rawtx", txid = "txid")
+ whenever { jadeService.signPsbt(JadeNetwork.REGTEST, "psbt") }.thenReturn("signed")
+ whenever { jadeService.finalizePsbt("psbt", "signed") }.thenReturn(completed)
+ val sut = createRepo()
+
+ val result = sut.signPsbt("psbt")
+
+ assertEquals(completed, result.getOrThrow())
+ }
+
+ @Test
+ fun `verifyAddress asks the device for the native segwit variant`() = test {
+ val sut = createRepo()
+
+ val result = sut.verifyAddress(HwFundingAddressType.NATIVE_SEGWIT, "m/84'/1'/0'/0/0", "bcrt1q")
+
+ assertTrue(result.isSuccess)
+ verify(jadeService).verifyAddress(JadeNetwork.REGTEST, JadeAddressVariant.WPKH, "m/84'/1'/0'/0/0", "bcrt1q")
+ }
+
+ private fun createRepo() = JadeRepo(
+ context = context,
+ jadeService = jadeService,
+ jadeTransport = jadeTransport,
+ hwWalletStore = hwWalletStore,
+ clock = Clock.System,
+ ioDispatcher = testDispatcher,
+ )
+
+ private fun versionInfo(state: JadeState, efuseMac: String? = EFUSE_MAC) = JadeVersionInfo(
+ jadeVersion = "1.0.41",
+ jadeState = state,
+ jadeNetworks = "ALL",
+ jadeHasPin = true,
+ boardType = "JADE_V1_1",
+ jadeConfig = null,
+ jadeFeatures = null,
+ idfVersion = null,
+ chipFeatures = null,
+ efuseMac = efuseMac,
+ batteryStatus = null,
+ jadeOtaMaxChunk = null,
+ )
+
+ private fun accountExport() = JadeAccountExport(
+ masterFingerprint = "deadbeef",
+ accountIndex = 0u,
+ accounts = listOf(
+ JadeAccount(variant = JadeAddressVariant.WPKH, xpub = "zpubNS", derivationPath = "m/84'/1'/0'"),
+ ),
+ )
+
+ private companion object {
+ const val USB_PATH = "/dev/bus/usb/001/007"
+ const val EFUSE_MAC = "246F288F6B64"
+ const val WALLET_ID = "jade:wallet"
+ val ALL_ACCOUNT_TYPES = listOf(
+ AccountType.LEGACY,
+ AccountType.WRAPPED_SEGWIT,
+ AccountType.NATIVE_SEGWIT,
+ AccountType.TAPROOT,
+ )
+ val WITHOUT_TAPROOT = ALL_ACCOUNT_TYPES - AccountType.TAPROOT
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt
index dc176ccb1f..5692b3ff63 100644
--- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt
@@ -41,6 +41,7 @@ import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.env.Env
import to.bitkit.ext.isTrezorDeviceBusy
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.KnownDevice
import to.bitkit.models.TransportType
import to.bitkit.models.toCoreNetwork
@@ -113,7 +114,7 @@ class TrezorRepoTest : BaseUnitTest() {
whenever(context.filesDir).thenReturn(tempFolder.root)
whenever(context.getString(R.string.hardware__connect_error)).thenReturn("Could not connect to your Trezor.")
whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE)
- whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList())
+ whenever { hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR) }.thenReturn(emptyList())
whenever { hwWalletStore.loadPendingNames() }.thenReturn(emptyMap())
stubAccountXpubFetch()
}
@@ -223,7 +224,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `initialize should load known devices on success`() = test {
val knownDevice = mockKnownDevice()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
sut = createSut()
val result = sut.initialize()
@@ -236,13 +237,13 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `initialize leaves wallet id blank until xpubs are available`() = test {
val knownDevice = mockKnownDevice(walletId = "")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
sut = createSut()
val result = sut.initialize()
assertTrue(result.isSuccess)
- verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull())
+ verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull(), anyOrNull())
assertEquals("", sut.state.value.knownDevices.single().walletId)
}
@@ -318,7 +319,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val known = mockDeviceInfo()
val nearby = mockDeviceInfo(id = "device-456", path = "/dev/trezor1")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(listOf(known, nearby))
sut = createSut()
@@ -352,7 +353,7 @@ class TrezorRepoTest : BaseUnitTest() {
val features = mockFeatures()
val device = mockDeviceInfo()
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features)
@@ -370,7 +371,7 @@ class TrezorRepoTest : BaseUnitTest() {
val features = mockFeatures()
val device = mockDeviceInfo()
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
// A device is usually not advertising yet right after the transport returns.
whenever(trezorService.scan()).thenReturn(emptyList(), listOf(device))
@@ -389,7 +390,7 @@ class TrezorRepoTest : BaseUnitTest() {
val transportRestored = MutableSharedFlow()
val device = mockDeviceInfo()
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false)))
@@ -408,7 +409,7 @@ class TrezorRepoTest : BaseUnitTest() {
val features = mockFeatures()
val bleDevice = mockDeviceInfo(id = "ble-1", transportType = TrezorTransportType.BLUETOOTH, path = "ble-path")
val usbDevice = mockDeviceInfo(id = "usb-1", transportType = TrezorTransportType.USB, path = "usb-path")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(
listOf(
mockKnownDevice(id = "ble-1", transportType = TransportType.BLUETOOTH),
mockKnownDevice(id = "usb-1"),
@@ -430,7 +431,7 @@ class TrezorRepoTest : BaseUnitTest() {
fun `repeated transport restored triggers run a single reconnect`() = test {
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features)
@@ -447,7 +448,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `autoReconnect bails while device awaits pin entry`() = test {
whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(true))
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
sut = createSut()
val result = sut.autoReconnect()
@@ -462,7 +463,7 @@ class TrezorRepoTest : BaseUnitTest() {
val transportRestored = MutableSharedFlow()
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(true))
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
sut = createSut()
transportRestored.emit(TransportType.USB)
@@ -481,7 +482,7 @@ class TrezorRepoTest : BaseUnitTest() {
val features = mockFeatures()
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
whenever(trezorTransport.needsPairingCode).thenReturn(needsPairingCode)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).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)
@@ -503,7 +504,7 @@ class TrezorRepoTest : BaseUnitTest() {
fun `onTransportRestored auto-reconnects to a known device`() = test {
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features)
@@ -522,7 +523,7 @@ class TrezorRepoTest : BaseUnitTest() {
transportType = TrezorTransportType.BLUETOOTH,
path = "ble-path",
)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(
listOf(mockKnownDevice(transportType = TransportType.BLUETOOTH))
)
whenever(trezorService.isConnected()).thenReturn(false)
@@ -539,7 +540,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `app foreground skips reconnect without a known bluetooth device`() = test {
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
sut = createSut()
sut.onAppForegrounded()
@@ -562,7 +563,7 @@ class TrezorRepoTest : BaseUnitTest() {
transportType = TrezorTransportType.BLUETOOTH,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features)
@@ -578,7 +579,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `warmUpKnownDevice skips non-bluetooth devices`() = test {
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
sut = createSut()
sut.initialize()
@@ -602,7 +603,7 @@ class TrezorRepoTest : BaseUnitTest() {
transportType = TrezorTransportType.BLUETOOTH,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false, true)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features)
@@ -620,7 +621,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `onTransportRestored skips usb device without permission`() = test {
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorTransport.hasUsbPermission(DEVICE_PATH)).thenReturn(false)
@@ -637,7 +638,7 @@ class TrezorRepoTest : BaseUnitTest() {
fun `autoReconnect resets a stale session before scanning`() = test {
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(mockKnownDevice()))
// The core still reports a session although the transport dropped underneath it.
whenever(trezorService.isConnected()).thenReturn(true)
whenever(trezorService.scan()).thenReturn(listOf(device))
@@ -722,7 +723,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
val saved = captor.firstValue.single()
assertEquals(DEVICE_ID, saved.id)
assertEquals(TransportType.USB, saved.transportType)
@@ -744,7 +745,7 @@ class TrezorRepoTest : BaseUnitTest() {
)
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(previousDevice))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(
@@ -768,7 +769,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet())
}
@@ -779,7 +780,7 @@ class TrezorRepoTest : BaseUnitTest() {
customLabel = "Savings",
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(standard))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Hidden("secret"))
@@ -790,7 +791,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
val saved = captor.firstValue
assertEquals(2, saved.size)
assertEquals(standard, saved.first())
@@ -816,7 +817,7 @@ class TrezorRepoTest : BaseUnitTest() {
walletId = "standard-wallet",
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(onOldTransport))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(onOldTransport))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(
@@ -829,7 +830,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
val added = captor.firstValue.single { it.id == DEVICE_ID }
assertEquals("No Pass", added.customLabel)
assertEquals("standard-wallet", added.walletId)
@@ -846,7 +847,7 @@ class TrezorRepoTest : BaseUnitTest() {
walletId = "standard-wallet",
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(unnamed))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(unnamed))
whenever { hwWalletStore.loadPendingNames() }.thenReturn(mapOf("standard-wallet" to "Cold Storage"))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
@@ -862,7 +863,11 @@ class TrezorRepoTest : BaseUnitTest() {
val captor = argumentCaptor>()
// Consumed in the same write as the entry that adopted it, so a failed save cannot lose it,
// and clearing the name later cannot fall back to it again.
- verify(hwWalletStore).saveKnownDevices(captor.capture(), eq(PendingNameUpdate("standard-wallet", name = null)))
+ verify(hwWalletStore).saveKnownDevices(
+ captor.capture(),
+ eq(PendingNameUpdate("standard-wallet", name = null)),
+ eq(HwWalletVendor.TREZOR),
+ )
assertEquals("Cold Storage", captor.firstValue.single { it.id == DEVICE_ID }.customLabel)
}
@@ -876,7 +881,7 @@ class TrezorRepoTest : BaseUnitTest() {
walletId = "standard-wallet",
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(stored))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(stored))
whenever { hwWalletStore.loadPendingNames() }.thenReturn(mapOf("standard-wallet" to "Cold Storage"))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
@@ -891,7 +896,11 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
// The pending name lost, so it is stale: dropping it keeps a later rename from falling back to it.
- verify(hwWalletStore).saveKnownDevices(captor.capture(), eq(PendingNameUpdate("standard-wallet", name = null)))
+ verify(hwWalletStore).saveKnownDevices(
+ captor.capture(),
+ eq(PendingNameUpdate("standard-wallet", name = null)),
+ eq(HwWalletVendor.TREZOR),
+ )
assertEquals("Renamed Here", captor.firstValue.single { it.id == DEVICE_ID }.customLabel)
}
@@ -905,7 +914,7 @@ class TrezorRepoTest : BaseUnitTest() {
walletId = "standard-wallet",
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(unnamed))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(unnamed))
// A passphrase wallet on the same device derives its own keys, so its name is its own.
whenever { hwWalletStore.loadPendingNames() }.thenReturn(mapOf("hidden-wallet" to "Hidden Stash"))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
@@ -920,7 +929,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), isNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), isNull(), eq(HwWalletVendor.TREZOR))
assertNull(captor.firstValue.single { it.id == DEVICE_ID }.customLabel)
}
@@ -933,7 +942,7 @@ class TrezorRepoTest : BaseUnitTest() {
trezorDeviceId = "old-device-id",
)
val features = mockFeatures(deviceId = "new-device-id")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(stale))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(stale))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
sut = createSut()
@@ -943,7 +952,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
val saved = captor.firstValue.single()
assertEquals("new-device-id", saved.trezorDeviceId)
assertTrue(saved.xpubs.values.none { it == "old-seed-xpub" })
@@ -957,7 +966,7 @@ class TrezorRepoTest : BaseUnitTest() {
trezorDeviceId = "same-device-id",
)
val features = mockFeatures(deviceId = "same-device-id")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(standard))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Hidden("secret"))
@@ -968,7 +977,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals(2, captor.firstValue.size)
assertEquals(standard, captor.firstValue.first())
}
@@ -982,7 +991,7 @@ class TrezorRepoTest : BaseUnitTest() {
passphraseProtected = true,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(misflagged))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(misflagged))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
sut = createSut()
@@ -992,7 +1001,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertFalse(captor.firstValue.single().passphraseProtected)
}
@@ -1005,7 +1014,7 @@ class TrezorRepoTest : BaseUnitTest() {
passphraseProtected = true,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(hidden))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(hidden))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.OnDevice)
@@ -1016,7 +1025,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertTrue(captor.firstValue.single().passphraseProtected)
}
@@ -1024,7 +1033,7 @@ class TrezorRepoTest : BaseUnitTest() {
fun `connect keeps the standard wallet unprotected when its keys are re-read`() = test {
val standard = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'"))
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(standard))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
sut = createSut()
@@ -1034,7 +1043,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
val saved = captor.firstValue.single()
assertFalse(saved.passphraseProtected)
}
@@ -1050,7 +1059,9 @@ class TrezorRepoTest : BaseUnitTest() {
val nativeSegwitPath = "m/84'/1'/0'"
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice(xpubs = previousXpubs)))
+ whenever(
+ hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)
+ ).thenReturn(listOf(mockKnownDevice(xpubs = previousXpubs)))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(
@@ -1074,7 +1085,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals(
mapOf(
"nativeSegwit" to "native-xpub",
@@ -1088,7 +1099,7 @@ class TrezorRepoTest : BaseUnitTest() {
fun `connect preserves stored custom label over stale state label`() = test {
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices())
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR))
.thenReturn(listOf(mockKnownDevice()))
.thenReturn(listOf(mockKnownDevice(customLabel = "Cold Storage")))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
@@ -1100,7 +1111,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals("Cold Storage", captor.lastValue.single().customLabel)
}
@@ -1236,7 +1247,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isFailure)
assertEquals(DEVICE_BUSY_MESSAGE, sut.state.value.error)
assertNull(sut.state.value.connectedDevice())
- verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull())
+ verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull(), anyOrNull())
}
@Test
@@ -1283,7 +1294,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isFailure)
assertNull(sut.state.value.connectedDevice())
- verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull())
+ verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull(), anyOrNull())
}
@Test
@@ -1307,7 +1318,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isFailure)
assertEquals("DeviceDisconnected", sut.state.value.error)
assertNull(sut.state.value.connectedDevice())
- verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull())
+ verify(hwWalletStore, never()).saveKnownDevices(any(), anyOrNull(), anyOrNull())
}
@Test
@@ -1417,7 +1428,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `resetState clears known devices and credentials`() = test {
val knownDevice = mockKnownDevice()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
sut = createSut()
sut.initialize()
@@ -1436,7 +1447,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val device = mockDeviceInfo()
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
sut = createSut()
@@ -1646,7 +1657,7 @@ class TrezorRepoTest : BaseUnitTest() {
@Test
fun `hasKnownDevice should match stored device path`() = test {
val knownDevice = mockKnownDevice(path = "/dev/bus/usb/001/002")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
sut = createSut()
assertTrue(sut.hasKnownDevice("/dev/bus/usb/001/002"))
@@ -1671,7 +1682,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val device = mockDeviceInfo()
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features)
whenever(trezorService.isConnected()).thenReturn(false)
@@ -1695,7 +1706,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val device = mockDeviceInfo()
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
sut = createSut()
@@ -1713,7 +1724,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val device = mockDeviceInfo()
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
sut = createSut()
@@ -1735,7 +1746,7 @@ class TrezorRepoTest : BaseUnitTest() {
transportType = TransportType.BLUETOOTH,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.scan()).thenReturn(emptyList())
whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features)
sut = createSut()
@@ -1772,7 +1783,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownOther = mockKnownDevice(id = otherDeviceId, path = "/other")
val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other")
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownOther))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownOther))
whenever(trezorService.scan()).thenReturn(listOf(otherDevice))
whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features)
sut = createSut()
@@ -1806,7 +1817,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownTarget = mockKnownDevice()
val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other")
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownTarget, knownOther))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownTarget, knownOther))
whenever(trezorService.scan()).thenReturn(listOf(otherDevice))
whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features)
sut = createSut()
@@ -1899,7 +1910,7 @@ class TrezorRepoTest : BaseUnitTest() {
transportType = TrezorTransportType.BLUETOOTH,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(emptyList(), emptyList(), listOf(device))
whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features)
@@ -1923,7 +1934,7 @@ class TrezorRepoTest : BaseUnitTest() {
path = bleDeviceId,
transportType = TransportType.BLUETOOTH,
)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(emptyList())
whenever(trezorService.connect(eq(bleDeviceId), any())).doAnswer { throw TrezorException.UserCancelled() }
@@ -1950,7 +1961,7 @@ class TrezorRepoTest : BaseUnitTest() {
path = bleDeviceId,
transportType = TrezorTransportType.BLUETOOTH,
)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(bleDeviceId), any())).doAnswer { throw TrezorException.DeviceBusy() }
@@ -2008,7 +2019,7 @@ class TrezorRepoTest : BaseUnitTest() {
val device = mockDeviceInfo()
val features = mockFeatures()
val addressResponse = mock()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.isConnected()).thenReturn(false)
whenever(trezorService.scan()).thenReturn(listOf(device))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
@@ -2041,7 +2052,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(device))
sut = createSut()
@@ -2060,7 +2071,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertNull(sut.state.value.error)
verify(trezorTransport).clearDeviceCredential(DEVICE_ID)
verify(trezorService).clearCredentials(DEVICE_ID)
- verify(hwWalletStore).saveKnownDevices(emptyList())
+ verify(hwWalletStore).saveKnownDevices(emptyList(), null, HwWalletVendor.TREZOR)
}
@Test
@@ -2068,7 +2079,7 @@ class TrezorRepoTest : BaseUnitTest() {
val knownDevice = mockKnownDevice()
val features = mockFeatures()
val device = mockDeviceInfo()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(device))
sut = createSut()
@@ -2088,14 +2099,14 @@ class TrezorRepoTest : BaseUnitTest() {
assertEquals("clear failed", sut.state.value.error)
verify(trezorTransport).clearDeviceCredential(DEVICE_ID)
verify(trezorService).clearCredentials(DEVICE_ID)
- verify(hwWalletStore).saveKnownDevices(emptyList())
+ verify(hwWalletStore).saveKnownDevices(emptyList(), null, HwWalletVendor.TREZOR)
}
@Test
fun `forgetDevice should preserve devices that are only in the store`() = test {
val knownDevice = mockKnownDevice()
val otherDevice = mockKnownDevice(id = "other-device", path = "/dev/trezor1")
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice, otherDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice, otherDevice))
sut = createSut()
val result = sut.forgetDevice(DEVICE_ID)
@@ -2104,7 +2115,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertEquals(listOf(otherDevice), sut.state.value.knownDevices)
verify(trezorTransport).clearDeviceCredential(DEVICE_ID)
verify(trezorService).clearCredentials(DEVICE_ID)
- verify(hwWalletStore).saveKnownDevices(listOf(otherDevice))
+ verify(hwWalletStore).saveKnownDevices(listOf(otherDevice), null, HwWalletVendor.TREZOR)
}
@Test
@@ -2115,14 +2126,14 @@ class TrezorRepoTest : BaseUnitTest() {
val sharedXpubs = mapOf("nativeSegwit" to "shared-native-xpub")
val overBluetooth = mockKnownDevice(id = "ble1", path = "ble:AA:BB", xpubs = sharedXpubs)
val overUsb = mockKnownDevice(id = "usb1", path = "/dev/trezor1", xpubs = sharedXpubs)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(overBluetooth, overUsb))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(overBluetooth, overUsb))
sut = createSut()
val result = sut.forgetDevice("usb1", walletKey = walletKeyOf(sharedXpubs))
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals(emptyList(), captor.lastValue)
assertTrue(sut.state.value.knownDevices.isEmpty())
}
@@ -2133,14 +2144,14 @@ class TrezorRepoTest : BaseUnitTest() {
val hiddenXpubs = mapOf("nativeSegwit" to "hidden-native-xpub")
val standard = mockKnownDevice(xpubs = standardXpubs)
val hidden = mockKnownDevice(xpubs = hiddenXpubs, passphraseProtected = true)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard, hidden))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(standard, hidden))
sut = createSut()
val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(hiddenXpubs))
assertTrue(result.isSuccess)
assertEquals(listOf(standard), sut.state.value.knownDevices)
- verify(hwWalletStore).saveKnownDevices(listOf(standard))
+ verify(hwWalletStore).saveKnownDevices(listOf(standard), null, HwWalletVendor.TREZOR)
verify(trezorTransport, never()).clearDeviceCredential(any())
verify(trezorService, never()).clearCredentials(any())
}
@@ -2151,7 +2162,7 @@ class TrezorRepoTest : BaseUnitTest() {
// one would make the passphrase prompt unable to ever succeed.
val features = mockFeatures()
val knownDevice = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "hidden-native-xpub"))
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(knownDevice))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
sut = createSut()
@@ -2191,7 +2202,7 @@ class TrezorRepoTest : BaseUnitTest() {
passphraseProtected = true,
)
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(forgotten, kept))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(forgotten, kept))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(
@@ -2221,7 +2232,7 @@ class TrezorRepoTest : BaseUnitTest() {
)
val kept = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "kept-native-xpub"), walletId = "kept-wallet")
val features = mockFeatures()
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(forgotten, kept))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(forgotten, kept))
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo()))
whenever(
@@ -2248,7 +2259,7 @@ class TrezorRepoTest : BaseUnitTest() {
val removed = mockKnownDevice(xpubs = removedXpubs, passphraseProtected = true)
val keptWhenCached = mockKnownDevice(xpubs = keptXpubs, passphraseProtected = true)
val keptWhenStored = keptWhenCached.copy(customLabel = "Pass B")
- whenever(hwWalletStore.loadKnownDevices())
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR))
.thenReturn(listOf(removed, keptWhenCached))
.thenReturn(listOf(removed, keptWhenStored))
sut = createSut()
@@ -2258,7 +2269,7 @@ class TrezorRepoTest : BaseUnitTest() {
assertTrue(result.isSuccess)
val captor = argumentCaptor>()
- verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull())
+ verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR))
assertEquals(listOf(keptWhenStored), captor.lastValue)
}
@@ -2266,13 +2277,13 @@ class TrezorRepoTest : BaseUnitTest() {
fun `forgetDevice clears credentials once the last identity is gone`() = test {
val standardXpubs = mapOf("nativeSegwit" to "standard-native-xpub")
val standard = mockKnownDevice(xpubs = standardXpubs)
- whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard))
+ whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(standard))
sut = createSut()
val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(standardXpubs))
assertTrue(result.isSuccess)
- verify(hwWalletStore).saveKnownDevices(emptyList())
+ verify(hwWalletStore).saveKnownDevices(emptyList(), null, HwWalletVendor.TREZOR)
verify(trezorTransport).clearDeviceCredential(DEVICE_ID)
verify(trezorService).clearCredentials(DEVICE_ID)
}
diff --git a/app/src/test/java/to/bitkit/services/JadeServiceTest.kt b/app/src/test/java/to/bitkit/services/JadeServiceTest.kt
new file mode 100644
index 0000000000..f784466da8
--- /dev/null
+++ b/app/src/test/java/to/bitkit/services/JadeServiceTest.kt
@@ -0,0 +1,25 @@
+package to.bitkit.services
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.junit.Test
+import org.mockito.kotlin.mock
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class JadeServiceTest : BaseUnitTest() {
+
+ private val transport = mock()
+
+ @Test
+ fun `finalizePsbt reaches the core function instead of recursing`() = test {
+ val sut = JadeService(transport)
+
+ // Without the native library the core call fails to link; a recursive call would overflow
+ // the stack instead, which is the regression this guards against.
+ val error = assertNotNull(runCatching { sut.finalizePsbt("not a psbt", "not a psbt") }.exceptionOrNull())
+
+ assertFalse(generateSequence(error) { it.cause }.any { it is StackOverflowError }, "error=$error")
+ }
+}
diff --git a/app/src/test/java/to/bitkit/services/JadeTransportTest.kt b/app/src/test/java/to/bitkit/services/JadeTransportTest.kt
new file mode 100644
index 0000000000..006f444464
--- /dev/null
+++ b/app/src/test/java/to/bitkit/services/JadeTransportTest.kt
@@ -0,0 +1,362 @@
+package to.bitkit.services
+
+import android.app.PendingIntent
+import android.content.Context
+import android.hardware.usb.UsbConstants
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbEndpoint
+import android.hardware.usb.UsbInterface
+import android.hardware.usb.UsbManager
+import com.synonym.bitkitcore.JadeTransportErrorCode
+import com.synonym.bitkitcore.JadeTransportKind
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.inOrder
+import org.mockito.kotlin.isNull
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class JadeTransportTest {
+
+ private val context = mock()
+ private val usbManager = mock()
+
+ @Before
+ fun setUp() {
+ whenever(context.applicationContext).thenReturn(context)
+ whenever(context.packageName).thenReturn("to.bitkit.dev")
+ whenever(context.getSystemService(Context.USB_SERVICE)).thenReturn(usbManager)
+ whenever(usbManager.deviceList).thenReturn(hashMapOf())
+ }
+
+ @Test
+ fun `quiet usb open does not request permission`() {
+ val device = cp210xDevice()
+ whenever(usbManager.deviceList).thenReturn(hashMapOf(USB_PATH to device))
+ whenever(usbManager.hasPermission(device)).thenReturn(false)
+ val sut = createSut()
+
+ val result = runBlocking {
+ sut.withUsbPermissionRequestsEnabled(false) {
+ sut.openDevice(USB_PATH)
+ }
+ }
+
+ assertFalse(result.success)
+ assertEquals("USB permission missing for '$USB_PATH'", result.error)
+ assertEquals(JadeTransportErrorCode.PERMISSION_DENIED, result.errorCode)
+ verify(usbManager, never()).requestPermission(eq(device), any())
+ }
+
+ @Test
+ fun `scanDevices can skip the bluetooth scan`() {
+ val sut = createSut()
+
+ val result = runBlocking {
+ sut.withBluetoothScanningEnabled(false) {
+ sut.scanDevices(3_000u)
+ }
+ }
+
+ assertTrue(result.isEmpty())
+ verify(context, never()).getSystemService(Context.BLUETOOTH_SERVICE)
+ }
+
+ @Test
+ fun `scanDevices lists only jade usb devices as serial devices`() {
+ val jade = cp210xDevice()
+ val jadePlus = cdcDevice(deviceName = "/dev/bus/usb/001/003")
+ val trezor = usbDevice(vendorId = 0x1209, productId = 0x53C1, deviceName = "/dev/bus/usb/001/004")
+ whenever(usbManager.deviceList).thenReturn(
+ hashMapOf(
+ "/dev/bus/usb/001/002" to jade,
+ "/dev/bus/usb/001/003" to jadePlus,
+ "/dev/bus/usb/001/004" to trezor
+ ),
+ )
+ val sut = createSut()
+
+ val result = runBlocking {
+ sut.withBluetoothScanningEnabled(false) {
+ sut.scanDevices(3_000u)
+ }
+ }
+
+ assertEquals(2, result.size)
+ assertTrue(result.all { it.transport == JadeTransportKind.SERIAL })
+ assertEquals(setOf("/dev/bus/usb/001/002", "/dev/bus/usb/001/003"), result.map { it.path }.toSet())
+ }
+
+ @Test
+ fun `selectUsbDriver picks cdc data and control interfaces`() {
+ val selection = assertNotNull(selectUsbDriver(cdcDevice()))
+
+ assertEquals(UsbDriverKind.CDC_ACM, selection.kind)
+ assertEquals(UsbConstants.USB_CLASS_CDC_DATA, selection.dataInterface.interfaceClass)
+ assertEquals(UsbConstants.USB_CLASS_COMM, selection.controlInterface?.interfaceClass)
+ assertEquals(UsbConstants.USB_DIR_IN, selection.readEndpoint.direction)
+ assertEquals(UsbConstants.USB_DIR_OUT, selection.writeEndpoint.direction)
+ }
+
+ @Test
+ fun `selectUsbDriver picks the cp210x interface`() {
+ val selection = assertNotNull(selectUsbDriver(cp210xDevice()))
+
+ assertEquals(UsbDriverKind.CP210X, selection.kind)
+ assertNull(selection.controlInterface)
+ }
+
+ @Test
+ fun `selectUsbDriver rejects an unsupported device`() {
+ assertNull(selectUsbDriver(usbDevice(vendorId = 0x1209, productId = 0x53C1)))
+ }
+
+ @Test
+ fun `opening a cp210x sends the serial setup sequence in order`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ val sut = createSut()
+
+ val result = sut.openDevice(USB_PATH)
+
+ assertTrue(result.success, result.error)
+ inOrder(connection) {
+ verify(connection).claimInterface(any(), eq(true))
+ verify(connection).controlTransfer(eq(0x41), eq(0x00), eq(0x0001), eq(0), isNull(), eq(0), any())
+ verify(connection).controlTransfer(
+ eq(0x41),
+ eq(0x1E),
+ eq(0),
+ eq(0),
+ eq(byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00)),
+ eq(4),
+ any(),
+ )
+ verify(connection).controlTransfer(eq(0x41), eq(0x03), eq(0x0800), eq(0), isNull(), eq(0), any())
+ verify(connection).controlTransfer(eq(0x41), eq(0x07), eq(0x0303), eq(0), isNull(), eq(0), any())
+ verify(connection).controlTransfer(eq(0x41), eq(0x12), eq(0x000F), eq(0), isNull(), eq(0), any())
+ }
+ }
+
+ @Test
+ fun `opening a cdc device claims both interfaces and sets the line state`() {
+ val device = cdcDevice()
+ val connection = openable(device)
+ val sut = createSut()
+
+ val result = sut.openDevice(USB_PATH)
+
+ assertTrue(result.success, result.error)
+ verify(connection).claimInterface(device.getInterface(0), true)
+ verify(connection).claimInterface(device.getInterface(1), true)
+ verify(connection).controlTransfer(
+ eq(0x21),
+ eq(0x20),
+ eq(0),
+ eq(0),
+ eq(byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00, 0x00, 0x00, 0x08)),
+ eq(7),
+ any(),
+ )
+ verify(connection).controlTransfer(eq(0x21), eq(0x22), eq(0x0003), eq(0), isNull(), eq(0), any())
+ }
+
+ @Test
+ fun `open fails and closes the connection when the interface cannot be claimed`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ whenever(connection.claimInterface(any(), any())).thenReturn(false)
+ val sut = createSut()
+
+ val result = sut.openDevice(USB_PATH)
+
+ assertFalse(result.success)
+ verify(connection).close()
+ }
+
+ @Test
+ fun `chunk sizes follow the transport`() {
+ val sut = createSut()
+
+ assertEquals(509u, sut.getChunkSize(USB_PATH))
+ assertEquals(20u, JadeTransport.chunkSizeForMtu(23))
+ assertEquals(244u, JadeTransport.chunkSizeForMtu(247))
+ assertEquals(509u, JadeTransport.chunkSizeForMtu(517))
+ assertEquals(1u, JadeTransport.chunkSizeForMtu(0))
+ assertEquals(20u, sut.getChunkSize("ble:AA:BB"))
+ }
+
+ @Test
+ fun `operations on a device that is not open report not connected`() {
+ val sut = createSut()
+
+ assertEquals(JadeTransportErrorCode.NOT_CONNECTED, sut.readChunk(USB_PATH, 250u).errorCode)
+ assertEquals(JadeTransportErrorCode.NOT_CONNECTED, sut.writeChunk(USB_PATH, byteArrayOf(1)).errorCode)
+ assertTrue(sut.closeDevice(USB_PATH).success)
+ }
+
+ @Test
+ fun `a read timeout is reported as an empty successful read`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ whenever(connection.bulkTransfer(any(), any(), any(), any())).thenReturn(-1)
+ val sut = createSut()
+ sut.openDevice(USB_PATH)
+
+ val result = sut.readChunk(USB_PATH, 250u)
+
+ assertTrue(result.success)
+ assertTrue(result.data.isEmpty())
+ assertNull(result.errorCode)
+ }
+
+ @Test
+ fun `a read returns only the bytes received into a max packet buffer`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ whenever(connection.bulkTransfer(any(), any(), any(), any())).thenAnswer {
+ val buffer = it.getArgument(1)
+ assertEquals(64, buffer.size)
+ buffer[0] = 7
+ buffer[1] = 8
+ 2
+ }
+ val sut = createSut()
+ sut.openDevice(USB_PATH)
+
+ val result = sut.readChunk(USB_PATH, 250u)
+
+ assertTrue(result.success)
+ assertContentEquals(byteArrayOf(7, 8), result.data)
+ }
+
+ @Test
+ fun `a read after unplugging reports a disconnect`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ whenever(connection.bulkTransfer(any(), any(), any(), any())).thenReturn(-1)
+ val sut = createSut()
+ sut.openDevice(USB_PATH)
+ whenever(usbManager.deviceList).thenReturn(hashMapOf())
+
+ val result = sut.readChunk(USB_PATH, 250u)
+
+ assertFalse(result.success)
+ assertEquals(JadeTransportErrorCode.DISCONNECTED, result.errorCode)
+ }
+
+ @Test
+ fun `a short write reports a timeout`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ whenever(connection.bulkTransfer(any(), any(), any(), any())).thenReturn(3)
+ val sut = createSut()
+ sut.openDevice(USB_PATH)
+
+ val result = sut.writeChunk(USB_PATH, ByteArray(10))
+
+ assertFalse(result.success)
+ assertEquals(JadeTransportErrorCode.TIMEOUT, result.errorCode)
+ }
+
+ @Test
+ fun `closing clears the modem lines and releases the interface once`() {
+ val device = cp210xDevice()
+ val connection = openable(device)
+ val sut = createSut()
+ sut.openDevice(USB_PATH)
+
+ assertTrue(sut.closeDevice(USB_PATH).success)
+ assertTrue(sut.closeDevice(USB_PATH).success)
+
+ verify(connection).controlTransfer(eq(0x41), eq(0x07), eq(0x0300), eq(0), isNull(), eq(0), any())
+ verify(connection).releaseInterface(device.getInterface(0))
+ verify(connection).close()
+ }
+
+ private fun openable(device: UsbDevice): UsbDeviceConnection {
+ val connection = mock()
+ whenever(usbManager.deviceList).thenReturn(hashMapOf(USB_PATH to device))
+ whenever(usbManager.hasPermission(device)).thenReturn(true)
+ whenever(usbManager.openDevice(device)).thenReturn(connection)
+ whenever(connection.claimInterface(any(), any())).thenReturn(true)
+ whenever(connection.controlTransfer(any(), any(), any(), any(), anyOrNull(), any(), any())).thenReturn(0)
+ return connection
+ }
+
+ private fun createSut() = JadeTransport(context = context)
+
+ private fun usbDevice(
+ vendorId: Int,
+ productId: Int,
+ interfaces: List = emptyList(),
+ deviceName: String = USB_PATH,
+ ): UsbDevice =
+ mock {
+ on { this.vendorId }.thenReturn(vendorId)
+ on { this.productId }.thenReturn(productId)
+ on { this.deviceName }.thenReturn(deviceName)
+ on { interfaceCount }.thenReturn(interfaces.size)
+ interfaces.forEachIndexed { index, usbInterface ->
+ on { getInterface(index) }.thenReturn(usbInterface)
+ }
+ }
+
+ private fun cp210xDevice(): UsbDevice = usbDevice(
+ vendorId = 0x10C4,
+ productId = 0xEA60,
+ interfaces = listOf(usbInterface(id = 0, interfaceClass = UsbConstants.USB_CLASS_VENDOR_SPEC, bulk = true)),
+ )
+
+ private fun cdcDevice(deviceName: String = USB_PATH): UsbDevice = usbDevice(
+ vendorId = 0x303A,
+ productId = 0x4001,
+ interfaces = listOf(
+ usbInterface(id = 0, interfaceClass = UsbConstants.USB_CLASS_COMM, bulk = false),
+ usbInterface(id = 1, interfaceClass = UsbConstants.USB_CLASS_CDC_DATA, bulk = true),
+ ),
+ deviceName = deviceName,
+ )
+
+ private fun usbInterface(id: Int, interfaceClass: Int, bulk: Boolean): UsbInterface {
+ val endpoints = if (bulk) {
+ listOf(
+ endpoint(UsbConstants.USB_DIR_IN, UsbConstants.USB_ENDPOINT_XFER_BULK),
+ endpoint(UsbConstants.USB_DIR_OUT, UsbConstants.USB_ENDPOINT_XFER_BULK),
+ )
+ } else {
+ listOf(endpoint(UsbConstants.USB_DIR_IN, UsbConstants.USB_ENDPOINT_XFER_INT))
+ }
+ return mock {
+ on { this.id }.thenReturn(id)
+ on { this.interfaceClass }.thenReturn(interfaceClass)
+ on { endpointCount }.thenReturn(endpoints.size)
+ endpoints.forEachIndexed { index, endpoint ->
+ on { getEndpoint(index) }.thenReturn(endpoint)
+ }
+ }
+ }
+
+ private fun endpoint(direction: Int, type: Int): UsbEndpoint = mock {
+ on { this.direction }.thenReturn(direction)
+ on { this.type }.thenReturn(type)
+ on { maxPacketSize }.thenReturn(64)
+ }
+
+ private companion object {
+ const val USB_PATH = "/dev/bus/usb/001/002"
+ }
+}
diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt
index 2545edaf78..2ad45f4b7a 100644
--- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt
@@ -23,6 +23,7 @@ import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class HwReceiveViewModelTest : BaseUnitTest() {
@@ -38,6 +39,7 @@ class HwReceiveViewModelTest : BaseUnitTest() {
fun setUp() {
whenever(hwWalletRepo.wallets).thenReturn(wallets)
whenever(hwWalletRepo.observeReceiveAddress(any(), any())).thenReturn(receiveAddress)
+ whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds)
sut = HwReceiveViewModel(context, hwWalletRepo)
}
diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt
index fcea3a0d35..39b02a95b4 100644
--- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt
@@ -8,7 +8,7 @@ class ReceiveInvoiceUtilsTest {
@Test
fun `getInvoiceForTab TREZOR returns only the hardware address`() {
val result = getInvoiceForTab(
- tab = ReceiveTab.TREZOR,
+ tab = ReceiveTab.HARDWARE,
bip21 = "bitcoin:software?lightning=lnbc1software",
bolt11 = "lnbc1software",
cjitInvoice = null,
@@ -23,7 +23,7 @@ class ReceiveInvoiceUtilsTest {
@Test
fun `getInvoiceForTab TREZOR applies hardware invoice details`() {
val result = getInvoiceForTab(
- tab = ReceiveTab.TREZOR,
+ tab = ReceiveTab.HARDWARE,
bip21 = "bitcoin:software",
bolt11 = "",
cjitInvoice = null,
@@ -40,7 +40,7 @@ class ReceiveInvoiceUtilsTest {
@Test
fun `getInvoiceForTab TREZOR omits a zero amount`() {
val result = getInvoiceForTab(
- tab = ReceiveTab.TREZOR,
+ tab = ReceiveTab.HARDWARE,
bip21 = "bitcoin:bc1qsoftware",
bolt11 = "",
cjitInvoice = null,
diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt
index 4486720529..37d8e0ae91 100644
--- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt
@@ -3,7 +3,6 @@ package to.bitkit.ui.screens.wallets.send
import android.content.Context
import com.synonym.bitkitcore.BroadcastException
import com.synonym.bitkitcore.TrezorException
-import com.synonym.bitkitcore.TrezorFeatures
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first
@@ -19,9 +18,11 @@ import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.R
+import to.bitkit.models.HwConnectedDevice
import to.bitkit.models.HwFundingBroadcastResult
import to.bitkit.models.HwFundingSignedTx
import to.bitkit.models.HwFundingTransaction
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.Toast
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.HwWalletRepo
@@ -33,6 +34,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class HwSendViewModelTest : BaseUnitTest() {
@@ -49,6 +51,7 @@ class HwSendViewModelTest : BaseUnitTest() {
@Before
fun setUp() {
whenever(coreService.activity).thenReturn(activityService)
+ whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds)
sut = HwSendViewModel(
context = context,
hwWalletRepo = hwWalletRepo,
@@ -80,7 +83,7 @@ class HwSendViewModelTest : BaseUnitTest() {
totalSpent = signedTx.totalSpent,
)
whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false)
- whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(mock()))
+ whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(connectedDevice()))
whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE))
.thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn(
@@ -178,7 +181,7 @@ class HwSendViewModelTest : BaseUnitTest() {
val toasts = mutableListOf()
val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false)
- whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(mock()))
+ whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(connectedDevice()))
whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE))
.thenReturn(Result.failure(timeout))
whenever(context.getString(R.string.common__error)).thenReturn("Error")
@@ -231,7 +234,7 @@ class HwSendViewModelTest : BaseUnitTest() {
totalSpent = signedTx.totalSpent,
)
whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false)
- whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(mock()))
+ whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(connectedDevice()))
whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE))
.thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn(Result.success(signedTx))
@@ -253,6 +256,8 @@ class HwSendViewModelTest : BaseUnitTest() {
val broadcast: HwFundingBroadcastResult,
)
+ private fun connectedDevice() = HwConnectedDevice(vendor = HwWalletVendor.TREZOR, id = "dev1")
+
private companion object {
const val WALLET_ID = "hardware-wallet"
const val ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8"
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 19405101e7..9f85a1fec9 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
@@ -2,10 +2,7 @@ package to.bitkit.ui.sheets.hardware
import android.content.Context
import app.cash.turbine.test
-import com.synonym.bitkitcore.TrezorDeviceInfo
import com.synonym.bitkitcore.TrezorException
-import com.synonym.bitkitcore.TrezorFeatures
-import com.synonym.bitkitcore.TrezorTransportType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -21,12 +18,14 @@ import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.R
+import to.bitkit.models.HwConnectedDevice
+import to.bitkit.models.HwDeviceState
+import to.bitkit.models.HwNearbyDevice
import to.bitkit.models.HwWallet
+import to.bitkit.models.HwWalletVendor
import to.bitkit.models.TransportType
-import to.bitkit.repositories.ConnectedTrezorDevice
import to.bitkit.repositories.HwPassphraseAlreadyAddedError
import to.bitkit.repositories.HwWalletRepo
-import to.bitkit.repositories.TrezorState
import to.bitkit.test.BaseUnitTest
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.AppError
@@ -41,7 +40,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
private val context = mock()
private val pairingCodeRequestId = MutableStateFlow(null)
private val wallets = MutableStateFlow>(persistentListOf())
- private val deviceState = MutableStateFlow(TrezorState())
+ private val deviceState = MutableStateFlow(HwDeviceState())
private lateinit var sut: HwConnectViewModel
@@ -62,7 +61,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
@Test
fun `onIntroContinue searches then advances to found with the first discovered device`() = test {
- deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")))
+ deviceState.value = HwDeviceState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")))
whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList()))
sut.effects.test {
@@ -79,7 +78,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
@Test
fun `onIntroContinue can search without bluetooth`() = test {
- deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("usb1", model = "Safe 5")))
+ deviceState.value = HwDeviceState(nearbyDevices = persistentListOf(deviceInfo("usb1", model = "Safe 5")))
whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList()))
sut.effects.test {
@@ -130,7 +129,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
@Test
fun `onConnectClick does not usb rescan before connecting a bluetooth route device`() = test {
- val connectedFeatures = features(model = "Safe 7")
+ val connectedFeatures = features(model = "Safe 7", id = "ble-device-id")
whenever(hwWalletRepo.connect("ble-device-id")).thenReturn(Result.success(connectedFeatures))
sut.onFoundRoute(deviceId = "ble-device-id", deviceModel = "Trezor Safe 7")
@@ -147,11 +146,11 @@ class HwConnectViewModelTest : BaseUnitTest() {
@Test
fun `onConnectClick uses scanned device id for usb route path`() = test {
val path = "/dev/bus/usb/001/002"
- val connectedFeatures = features(model = "Safe 5")
+ val connectedFeatures = features(model = "Safe 5", id = "core-usb-id")
val usbDevice = deviceInfo(
id = "core-usb-id",
model = "Safe 5",
- transportType = TrezorTransportType.USB,
+ transportType = TransportType.USB,
path = path,
)
whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(listOf(usbDevice)))
@@ -292,7 +291,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
// Discovery skips known devices, so a paired Trezor only reaches the paired step — where
// its passphrase wallets are added — through this fallback.
val paired = deviceInfo("dev1", model = "Safe 3")
- deviceState.value = TrezorState(nearbyDevices = persistentListOf())
+ deviceState.value = HwDeviceState(nearbyDevices = persistentListOf())
whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(listOf(paired)))
whenever { hwWalletRepo.hasKnownDevice("dev1") }.thenReturn(true)
@@ -308,7 +307,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
@Test
fun `keeps searching when the only device found is neither new nor paired`() = test {
- deviceState.value = TrezorState(nearbyDevices = persistentListOf())
+ deviceState.value = HwDeviceState(nearbyDevices = persistentListOf())
whenever(hwWalletRepo.scan(includeBluetooth = true))
.thenReturn(Result.success(listOf(deviceInfo("other", model = "Safe 3"))))
whenever { hwWalletRepo.hasKnownDevice("other") }.thenReturn(false)
@@ -370,9 +369,9 @@ class HwConnectViewModelTest : BaseUnitTest() {
val hidden = hwWallet("dev1", name = "Pass A", balance = 10_000uL, walletId = "hidden-wallet")
val standard = hwWallet("dev1", name = "No Pass", balance = 27uL, walletId = "standard-wallet")
wallets.value = persistentListOf(hidden, standard)
- deviceState.value = TrezorState(
+ deviceState.value = HwDeviceState(
nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")),
- connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = "standard-wallet"),
+ connected = HwConnectedDevice(vendor = HwWalletVendor.TREZOR, id = "dev1", walletId = "standard-wallet"),
)
val connectedFeatures = features(model = "Safe 3")
whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList()))
@@ -393,9 +392,9 @@ class HwConnectViewModelTest : BaseUnitTest() {
// The paired wallet has not reached the list yet, so the typed name would otherwise be
// dropped and the flow closed instead of finished.
val connectedFeatures = features(model = "Safe 3")
- deviceState.value = TrezorState(
+ deviceState.value = HwDeviceState(
nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")),
- connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = "wallet-1"),
+ connected = HwConnectedDevice(vendor = HwWalletVendor.TREZOR, id = "dev1", walletId = "wallet-1"),
)
whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList()))
whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures))
@@ -549,7 +548,7 @@ class HwConnectViewModelTest : BaseUnitTest() {
}
private suspend fun givenDeviceFound() {
- deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")))
+ deviceState.value = HwDeviceState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")))
whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList()))
sut.onIntroContinue()
}
@@ -557,24 +556,23 @@ class HwConnectViewModelTest : BaseUnitTest() {
private fun deviceInfo(
id: String,
model: String?,
- transportType: TrezorTransportType = TrezorTransportType.BLUETOOTH,
+ transportType: TransportType = TransportType.BLUETOOTH,
path: String = "ble:$id",
- ) = TrezorDeviceInfo(
+ ) = HwNearbyDevice(
+ vendor = HwWalletVendor.TREZOR,
id = id,
+ path = path,
transportType = transportType,
name = null,
- path = path,
- label = null,
model = model,
- isBootloader = false,
)
- private fun features(model: String?): TrezorFeatures {
- val features = mock()
- whenever(features.label).thenReturn(null)
- whenever(features.model).thenReturn(model)
- return features
- }
+ private fun features(model: String?, id: String = "dev1") = HwConnectedDevice(
+ vendor = HwWalletVendor.TREZOR,
+ id = id,
+ label = null,
+ model = model,
+ )
private fun hwWallet(
deviceId: String,
diff --git a/app/src/test/java/to/bitkit/ui/utils/HwUsbIdTest.kt b/app/src/test/java/to/bitkit/ui/utils/HwUsbIdTest.kt
new file mode 100644
index 0000000000..a348bb08de
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ui/utils/HwUsbIdTest.kt
@@ -0,0 +1,39 @@
+package to.bitkit.ui.utils
+
+import android.hardware.usb.UsbDevice
+import org.junit.Test
+import org.mockito.kotlin.mock
+import to.bitkit.models.HwWalletVendor
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class HwUsbIdTest {
+
+ @Test
+ fun `jade usb ids resolve to the blockstream vendor`() {
+ assertEquals(HwWalletVendor.BLOCKSTREAM, usbDevice(0x10C4, 0xEA60).hwVendorOrNull())
+ assertEquals(HwWalletVendor.BLOCKSTREAM, usbDevice(0x303A, 0x4001).hwVendorOrNull())
+ assertEquals(HwWalletVendor.BLOCKSTREAM, usbDevice(0x303A, 0x1001).hwVendorOrNull())
+ }
+
+ @Test
+ fun `trezor usb ids resolve to the trezor vendor and flag the bootloader`() {
+ assertEquals(HwWalletVendor.TREZOR, usbDevice(0x1209, 0x53C1).hwVendorOrNull())
+ assertEquals(HwWalletVendor.TREZOR, usbDevice(0x534C, 0x0001).hwVendorOrNull())
+ assertTrue(usbDevice(0x1209, 0x53C0).isHwBootloader())
+ assertFalse(usbDevice(0x1209, 0x53C1).isHwBootloader())
+ }
+
+ @Test
+ fun `unknown usb ids resolve to no vendor`() {
+ assertNull(usbDevice(0x1A86, 0x7523).hwVendorOrNull())
+ assertFalse(usbDevice(0x1A86, 0x7523).isHwBootloader())
+ }
+
+ private fun usbDevice(vendorId: Int, productId: Int): UsbDevice = mock {
+ on { this.vendorId }.thenReturn(vendorId)
+ on { this.productId }.thenReturn(productId)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/utils/HwErrorPresenterTest.kt b/app/src/test/java/to/bitkit/utils/HwErrorPresenterTest.kt
new file mode 100644
index 0000000000..742253108f
--- /dev/null
+++ b/app/src/test/java/to/bitkit/utils/HwErrorPresenterTest.kt
@@ -0,0 +1,53 @@
+package to.bitkit.utils
+
+import android.content.Context
+import com.synonym.bitkitcore.JadeException
+import com.synonym.bitkitcore.TrezorException
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import to.bitkit.R
+import kotlin.test.assertEquals
+
+class HwErrorPresenterTest {
+
+ private val context = mock()
+
+ @Before
+ fun setUp() {
+ whenever(context.getString(R.string.hardware__jade_invalid_pin)).thenReturn("wrong pin")
+ whenever(context.getString(R.string.hardware__jade_uninitialized)).thenReturn("not set up")
+ whenever(context.getString(R.string.hardware__jade_firmware_outdated)).thenReturn("old firmware")
+ whenever(context.getString(R.string.hardware__jade_psbt_too_large)).thenReturn("too large")
+ whenever(context.getString(R.string.hardware__jade_network_mismatch)).thenReturn("wrong network")
+ whenever(context.getString(R.string.hardware__jade_device_busy)).thenReturn("jade busy")
+ whenever(context.getString(R.string.hardware__jade_pinserver_error)).thenReturn("pinserver")
+ whenever(context.getString(R.string.hardware__device_busy)).thenReturn("trezor busy")
+ whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error")
+ }
+
+ @Test
+ fun `maps typed jade errors to their messages`() {
+ assertEquals("wrong pin", HwErrorPresenter.userMessage(context, JadeException.InvalidPin()))
+ assertEquals("not set up", HwErrorPresenter.userMessage(context, JadeException.DeviceUninitialized()))
+ assertEquals("old firmware", HwErrorPresenter.userMessage(context, JadeException.UnsupportedFirmware("a", "b")))
+ assertEquals("too large", HwErrorPresenter.userMessage(context, JadeException.PsbtTooLarge(20_000uL, 16_384uL)))
+ assertEquals("wrong network", HwErrorPresenter.userMessage(context, JadeException.NetworkMismatch("x")))
+ assertEquals("jade busy", HwErrorPresenter.userMessage(context, JadeException.DeviceLocked()))
+ assertEquals("pinserver", HwErrorPresenter.userMessage(context, JadeException.PinServerException("x")))
+ }
+
+ @Test
+ fun `maps a wrapped jade error through the cause chain`() {
+ assertEquals("wrong pin", HwErrorPresenter.userMessage(context, AppError(JadeException.InvalidPin())))
+ }
+
+ @Test
+ fun `falls back to the trezor presenter for other errors`() {
+ assertEquals("trezor busy", HwErrorPresenter.userMessage(context, TrezorException.DeviceBusy()))
+ assertEquals("boom", HwErrorPresenter.userMessage(context, AppError("boom"), fallback = "fallback"))
+ assertEquals("fallback", HwErrorPresenter.userMessage(context, AppError(""), fallback = "fallback"))
+ assertEquals("connect error", HwErrorPresenter.userMessage(context, JadeException.Timeout()))
+ }
+}
diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
index e0e0214aeb..bbb3df5aa4 100644
--- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
@@ -11,7 +11,6 @@ import com.synonym.bitkitcore.IBtInfo
import com.synonym.bitkitcore.IBtInfoOptions
import com.synonym.bitkitcore.ReverseSwapResponse
import com.synonym.bitkitcore.TrezorException
-import com.synonym.bitkitcore.TrezorFeatures
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.CompletableDeferred
@@ -55,12 +54,14 @@ import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.env.Defaults
import to.bitkit.models.BalanceState
+import to.bitkit.models.HwConnectedDevice
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.HwWalletVendor
import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransferType
@@ -115,6 +116,7 @@ class TransferViewModelTest : BaseUnitTest() {
@Before
fun setUp() {
+ whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds)
whenever(feeResponse.feeSat).thenReturn(LSP_FEE)
whenever(feeResponse.networkFeeSat).thenReturn(NETWORK_FEE)
whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE)
@@ -1193,7 +1195,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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())).thenReturn(Result.success(signed))
@@ -1252,7 +1254,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(
@@ -1324,7 +1326,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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())).thenReturn(Result.success(signed))
@@ -1380,7 +1382,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") }
.thenReturn(Result.success(Unit))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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())).thenReturn(Result.success(signed))
@@ -1415,7 +1417,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") }
.thenReturn(Result.success(Unit))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "secret")
sut.onHwPassphraseDismiss()
@@ -1468,7 +1470,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull()))
.thenReturn(Result.failure(AppError("fee unavailable")))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
@@ -1508,7 +1510,7 @@ class TransferViewModelTest : BaseUnitTest() {
@Test
fun `cancelHardwareTransfer stops an in-flight hardware transfer`() = test {
val order = previewBtOrder()
- val connectResult = CompletableDeferred>()
+ val connectResult = CompletableDeferred>()
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)).doSuspendableAnswer { connectResult.await() }
whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit))
@@ -1564,7 +1566,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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())).thenReturn(Result.failure(timeout))
@@ -1591,7 +1593,7 @@ class TransferViewModelTest : BaseUnitTest() {
satsPerVByte = FEE_RATE,
)
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any()))
.thenReturn(Result.success(funding))
@@ -1640,7 +1642,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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()))
@@ -1667,7 +1669,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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()))
@@ -1693,7 +1695,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
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")))
@@ -1722,7 +1724,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any()))
.thenReturn(Result.failure(timeout))
@@ -1811,7 +1813,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -1855,7 +1857,7 @@ class TransferViewModelTest : BaseUnitTest() {
)
val signed = signedFunding(funding)
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -1911,7 +1913,7 @@ class TransferViewModelTest : BaseUnitTest() {
}
}
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -1958,7 +1960,7 @@ class TransferViewModelTest : BaseUnitTest() {
totalSpent = order.feeSat + MINING_FEE,
)
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -2018,7 +2020,7 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -2048,7 +2050,7 @@ class TransferViewModelTest : BaseUnitTest() {
)
val signed = signedFunding(funding)
whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID))
- .thenReturn(Result.success(mock()))
+ .thenReturn(Result.success(connectedHardwareDevice()))
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed))
@@ -2342,6 +2344,8 @@ class TransferViewModelTest : BaseUnitTest() {
totalSpent = funding.totalSpent,
)
+ private fun connectedHardwareDevice() = HwConnectedDevice(vendor = HwWalletVendor.TREZOR, id = "dev1")
+
private fun hwWallet(walletId: String, connected: Boolean) = HwWallet(
id = walletId,
name = "Trezor",
diff --git a/changelog.d/next/1231.added.md b/changelog.d/next/1231.added.md
new file mode 100644
index 0000000000..9aea63c111
--- /dev/null
+++ b/changelog.d/next/1231.added.md
@@ -0,0 +1 @@
+Added support for pairing Blockstream Jade and Jade Plus hardware wallets over USB and Bluetooth, including watch-only balances, address verification and on-device transaction signing.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 17cbb15651..10cd3837d7 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.5.14" }
+bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.15" }
paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc46" }
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md
index 7d4e5ae584..2d4cfa3d8f 100644
--- a/journeys/hardware-wallet/README.md
+++ b/journeys/hardware-wallet/README.md
@@ -140,3 +140,10 @@ balance can be much larger than the displayed AVAILABLE amount because MAX is ca
Blocktank channel headroom. After signing, decode the funding transaction and compare the
activity DB row: the on-chain activity fee should be the composed mining fee, while the
funding output should equal the final Blocktank `order.feeSat`.
+
+## Blockstream Jade
+
+There is no Jade emulator in `bitkit-docker`, so the Jade flows (Connect Hardware over USB or
+Bluetooth, receive-address verification and on-device signing) are covered by unit tests
+(`JadeRepoTest.kt`, `JadeTransportTest.kt`, `HwWalletRepoTest.kt`) and by manual runs against a
+physical Jade. The journeys in this folder stay Trezor-only.