Skip to content

Commit 25fd65a

Browse files
authored
Merge pull request #973 from synonymdev/feat/trezor-pin-passphrase-ui
feat: add trezor pin entry and hidden wallets
2 parents 0012bec + 5523d8b commit 25fd65a

13 files changed

Lines changed: 788 additions & 36 deletions

File tree

app/src/main/java/to/bitkit/repositories/TrezorRepo.kt

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import com.synonym.bitkitcore.TrezorSignedMessageResponse
2121
import com.synonym.bitkitcore.TrezorSignedTx
2222
import com.synonym.bitkitcore.TrezorTransportType
2323
import com.synonym.bitkitcore.WalletParams
24+
import com.synonym.bitkitcore.WalletSelection
2425
import dagger.hilt.android.qualifiers.ApplicationContext
2526
import kotlinx.collections.immutable.ImmutableList
2627
import kotlinx.collections.immutable.persistentListOf
2728
import kotlinx.collections.immutable.toImmutableList
2829
import kotlinx.coroutines.CoroutineDispatcher
2930
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.delay
3032
import kotlinx.coroutines.flow.MutableStateFlow
3133
import kotlinx.coroutines.flow.asStateFlow
3234
import kotlinx.coroutines.flow.launchIn
@@ -42,6 +44,8 @@ import to.bitkit.models.toCoreNetwork
4244
import to.bitkit.services.TrezorDebugLog
4345
import to.bitkit.services.TrezorService
4446
import to.bitkit.services.TrezorTransport
47+
import to.bitkit.services.TrezorUiHandler
48+
import to.bitkit.services.TrezorWalletMode
4549
import to.bitkit.utils.AppError
4650
import to.bitkit.utils.Logger
4751
import java.io.File
@@ -55,13 +59,15 @@ class TrezorRepo @Inject constructor(
5559
@ApplicationContext private val context: Context,
5660
private val trezorService: TrezorService,
5761
private val trezorTransport: TrezorTransport,
62+
private val trezorUiHandler: TrezorUiHandler,
5863
private val trezorStore: TrezorStore,
5964
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
6065
) {
6166
companion object {
6267
private const val TAG = "TrezorRepo"
6368
private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0"
6469
private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'"
70+
private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L
6571
}
6672

6773
private val _state = MutableStateFlow(TrezorState())
@@ -87,6 +93,64 @@ class TrezorRepo @Inject constructor(
8793
trezorTransport.cancelPairingCode()
8894
}
8995

96+
val needsPinEntry = trezorUiHandler.needsPinEntry
97+
98+
fun submitPin(pin: String) {
99+
trezorUiHandler.submitPin(pin)
100+
}
101+
102+
fun cancelPin() {
103+
trezorUiHandler.cancelPin()
104+
}
105+
106+
val walletMode = trezorUiHandler.walletMode
107+
108+
/**
109+
* Reset to the standard wallet and clear any selected passphrase, without
110+
* reconnecting. Call this when the user explicitly picks a device from a
111+
* list ([connect]/[connectKnownDevice]) so a passphrase or on-device
112+
* selection left over from a previously connected device isn't silently
113+
* applied to the newly selected one.
114+
*
115+
* Silent reconnects ([autoReconnect]/[ensureConnected]) deliberately skip
116+
* this, so a dropped link reopens the same hidden wallet the user was using.
117+
*/
118+
fun resetWalletSelection() {
119+
trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD)
120+
}
121+
122+
/**
123+
* Switch between the standard wallet and a passphrase (hidden) wallet.
124+
*
125+
* The Trezor caches the passphrase for the whole session, so switching
126+
* requires a fresh session: this sets the desired mode, then disconnects
127+
* and reconnects. The new mode takes effect on the next wallet operation.
128+
*/
129+
suspend fun setWalletMode(
130+
mode: TrezorWalletMode,
131+
passphrase: String = "",
132+
): Result<TrezorFeatures> = withContext(ioDispatcher) {
133+
runCatching {
134+
val deviceId = _state.value.connectedDeviceId
135+
?: throw AppError("No connected Trezor")
136+
TrezorDebugLog.log("WALLET_MODE", "Switching to $mode, resetting session for $deviceId")
137+
// Reset the session via disconnect/reconnect. disconnect() resets the
138+
// UI handler's wallet mode to standard, so set the desired mode AFTER
139+
// the disconnect and right before reconnecting.
140+
runCatching { disconnect() }
141+
// Reconnect by id WITHOUT a scan: scan() clears the discovered-device
142+
// cache and a scan right after a disconnect usually finds nothing,
143+
// whereas the cached handle (and direct address resolution) still work.
144+
delay(WALLET_MODE_RECONNECT_DELAY_MS)
145+
// Record the selection on the handler: THP reads it via
146+
// currentSelection() to bind the passphrase at session creation,
147+
// while non-THP devices re-request it mid-operation and are answered
148+
// from the same value. connect() then derives the wallet from it.
149+
trezorUiHandler.setWalletMode(mode, passphrase)
150+
connect(deviceId).getOrThrow()
151+
}
152+
}
153+
90154
suspend fun initialize(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
91155
runCatching {
92156
val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json"
@@ -131,7 +195,7 @@ class TrezorRepo @Inject constructor(
131195
runCatching {
132196
_state.update { it.copy(isConnecting = true, error = null) }
133197
TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId")
134-
val features = connectWithThpRetry(deviceId)
198+
val features = connectWithThpRetry(deviceId, trezorUiHandler.currentSelection())
135199
TrezorDebugLog.log("CONNECT", "connect() succeeded: label=${features.label}, model=${features.model}")
136200
val deviceInfo = _state.value.nearbyDevices.find { it.id == deviceId }
137201
?: _state.value.knownDevices.find { it.id == deviceId }?.let { known ->
@@ -319,6 +383,14 @@ class TrezorRepo @Inject constructor(
319383
suspend fun disconnect(): Result<Unit> = withContext(ioDispatcher) {
320384
TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}")
321385
val result = runCatching { trezorService.disconnect() }
386+
// Mirror the core: trezorService.disconnect() resets the session
387+
// passphrase to the standard wallet, so reset the UI handler's wallet
388+
// mode too. This keeps the THP path, the legacy PassphraseRequest
389+
// callback, and the displayed mode consistent on the next (re)connect —
390+
// a hidden wallet must be re-selected explicitly after an explicit
391+
// disconnect. (A transient external disconnect does not call this and so
392+
// retains the selection, matching the core's behaviour.)
393+
trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD)
322394
_state.update {
323395
it.copy(connected = null, lastAddress = null, lastPublicKey = null)
324396
}
@@ -428,20 +500,13 @@ class TrezorRepo @Inject constructor(
428500
"RECONNECT",
429501
"Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}",
430502
)
431-
val exactMatch = scannedDevices.find { it.id == deviceId }
432-
val knownIds = _state.value.knownDevices.map { it.id }.toSet()
433-
val usbDevice = scannedDevices.find {
434-
it.transportType == TrezorTransportType.USB && it.id in knownIds
435-
}
436-
val device = if (exactMatch?.transportType == TrezorTransportType.BLUETOOTH && usbDevice != null) {
437-
TrezorDebugLog.log("RECONNECT", "Preferring USB over BLE")
438-
usbDevice
439-
} else {
440-
exactMatch ?: throw AppError("Device not found nearby — is it powered on?")
441-
}
503+
// Honor the transport the user selected — connect to exactly the
504+
// entry they tapped instead of overriding Bluetooth with USB.
505+
val device = scannedDevices.find { it.id == deviceId }
506+
?: throw AppError("Device not found nearby — is it powered on?")
442507
TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}")
443508
TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...")
444-
val features = connectWithThpRetry(device.id)
509+
val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection())
445510
TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}")
446511
addOrUpdateKnownDevice(device, features)
447512
_state.update {
@@ -461,6 +526,9 @@ class TrezorRepo @Inject constructor(
461526
TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId")
462527
val disconnectResult = if (_state.value.connectedDeviceId == deviceId) {
463528
runCatching { trezorService.disconnect() }.also {
529+
// Clear any cached host passphrase so it can't be reused
530+
// against a different device on a later connect.
531+
trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD)
464532
_state.update { it.copy(connected = null) }
465533
}
466534
} else {
@@ -541,7 +609,7 @@ class TrezorRepo @Inject constructor(
541609
val devices = trezorService.scan()
542610
val device = devices.find { it.id == deviceId }
543611
?: throw AppError("Device not found during reconnect")
544-
val features = connectWithThpRetry(device.id)
612+
val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection())
545613
_state.update { it.copy(connected = ConnectedTrezorDevice(id = deviceId, features = features)) }
546614
}
547615

@@ -555,11 +623,14 @@ class TrezorRepo @Inject constructor(
555623
}
556624
}
557625

558-
private suspend fun connectWithThpRetry(deviceId: String): TrezorFeatures {
626+
private suspend fun connectWithThpRetry(
627+
deviceId: String,
628+
selection: WalletSelection,
629+
): TrezorFeatures {
559630
TrezorDebugLog.log("THPRetry", "First connect attempt for: $deviceId")
560631
logCredentialFileState(deviceId, "BEFORE 1st attempt")
561632
return runCatching {
562-
trezorService.connect(deviceId)
633+
trezorService.connect(deviceId, selection)
563634
}.onSuccess {
564635
logCredentialFileState(deviceId, "AFTER 1st attempt (success)")
565636
TrezorDebugLog.log("THPRetry", "First attempt succeeded")
@@ -573,7 +644,7 @@ class TrezorRepo @Inject constructor(
573644
TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...")
574645
Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG)
575646
logCredentialFileState(deviceId, "BEFORE 2nd attempt")
576-
val result = trezorService.connect(deviceId)
647+
val result = trezorService.connect(deviceId, selection)
577648
logCredentialFileState(deviceId, "AFTER 2nd attempt (success)")
578649
TrezorDebugLog.log("THPRetry", "Second attempt succeeded")
579650
result
@@ -591,6 +662,11 @@ class TrezorRepo @Inject constructor(
591662

592663
private fun isRetryableError(e: Throwable): Boolean {
593664
val msg = e.message?.lowercase() ?: return false
665+
// A rejected session (wrong passphrase, or the user cancelling on-device
666+
// passphrase entry) is a definitive failure, not a transient THP/transport
667+
// hiccup. Retrying it just re-prompts the device and risks wedging the
668+
// connection, so don't treat ThpCreateNewSession rejections as retryable.
669+
if ("rejected" in msg) return false
594670
return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg
595671
}
596672
}

app/src/main/java/to/bitkit/services/TrezorService.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.synonym.bitkitcore.TrezorSignMessageParams
1818
import com.synonym.bitkitcore.TrezorSignedMessageResponse
1919
import com.synonym.bitkitcore.TrezorSignedTx
2020
import com.synonym.bitkitcore.TrezorVerifyMessageParams
21+
import com.synonym.bitkitcore.WalletSelection
2122
import com.synonym.bitkitcore.onchainBroadcastRawTx
2223
import com.synonym.bitkitcore.onchainComposeTransaction
2324
import com.synonym.bitkitcore.onchainGetAccountInfo
@@ -36,6 +37,7 @@ import com.synonym.bitkitcore.trezorIsInitialized
3637
import com.synonym.bitkitcore.trezorListDevices
3738
import com.synonym.bitkitcore.trezorScan
3839
import com.synonym.bitkitcore.trezorSetTransportCallback
40+
import com.synonym.bitkitcore.trezorSetUiCallback
3941
import com.synonym.bitkitcore.trezorSignMessage
4042
import com.synonym.bitkitcore.trezorSignTxFromPsbt
4143
import com.synonym.bitkitcore.trezorVerifyMessage
@@ -48,6 +50,7 @@ import com.synonym.bitkitcore.Network as BitkitCoreNetwork
4850
@Singleton
4951
class TrezorService @Inject constructor(
5052
private val transport: TrezorTransport,
53+
private val uiHandler: TrezorUiHandler,
5154
) {
5255
@Volatile
5356
private var callbackRegistered = false
@@ -57,6 +60,7 @@ class TrezorService @Inject constructor(
5760
synchronized(this) {
5861
if (!callbackRegistered) {
5962
trezorSetTransportCallback(transport)
63+
trezorSetUiCallback(uiHandler)
6064
callbackRegistered = true
6165
}
6266
}
@@ -88,9 +92,14 @@ class TrezorService @Inject constructor(
8892
}
8993
}
9094

91-
suspend fun connect(deviceId: String): TrezorFeatures {
95+
/**
96+
* Connect to a device, opening the wallet given by [selection]. On THP
97+
* devices (Safe 5/7) the passphrase is bound to the session at creation, so
98+
* it is supplied per-connect rather than cached between calls.
99+
*/
100+
suspend fun connect(deviceId: String, selection: WalletSelection): TrezorFeatures {
92101
return ServiceQueue.CORE.background {
93-
trezorConnect(deviceId = deviceId)
102+
trezorConnect(deviceId = deviceId, selection = selection)
94103
}
95104
}
96105

app/src/main/java/to/bitkit/services/TrezorTransport.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,11 @@ class TrezorTransport @Inject constructor(
834834
@SuppressLint("MissingPermission")
835835
private fun openBleDevice(path: String): TrezorTransportWriteResult {
836836
val address = path.removePrefix("ble:")
837+
// Prefer a handle from a recent scan, but fall back to resolving the
838+
// address directly so we can reconnect to a known device without a
839+
// fresh scan — a scan right after a disconnect often finds nothing yet.
837840
val device = discoveredBleDevices[address]
841+
?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull()
838842
?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path")
839843

840844
closeBleDevice(path)

0 commit comments

Comments
 (0)