Skip to content

Commit 1d82489

Browse files
committed
feat: prefer restored transport on hw reconnect
1 parent 39ee64f commit 1d82489

10 files changed

Lines changed: 73 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import to.bitkit.ext.create
3232
import to.bitkit.ext.rawId
3333
import to.bitkit.models.HwWallet
3434
import to.bitkit.models.HwWalletReceivedTx
35+
import to.bitkit.models.TransportType
3536
import to.bitkit.models.toAccountType
3637
import to.bitkit.models.toAddressType
3738
import to.bitkit.models.toCoreNetwork
@@ -72,7 +73,7 @@ class HwWalletRepo @Inject constructor(
7273
val receivedTxs: SharedFlow<HwWalletReceivedTx> = _receivedTxs.asSharedFlow()
7374

7475
/** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */
75-
fun onTransportRestored() = trezorRepo.onTransportRestored()
76+
fun onTransportRestored(transportType: TransportType) = trezorRepo.onTransportRestored(transportType)
7677

7778
/** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */
7879
val needsPairingCode = trezorRepo.needsPairingCode

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,10 @@ class TrezorRepo @Inject constructor(
489489

490490
fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty()
491491

492-
suspend fun autoReconnect(walletIndex: Int = 0): Result<TrezorFeatures> = withContext(ioDispatcher) {
492+
suspend fun autoReconnect(
493+
walletIndex: Int = 0,
494+
preferredTransport: TransportType? = null,
495+
): Result<TrezorFeatures> = withContext(ioDispatcher) {
493496
if (isConnectInProgress()) {
494497
// A live handshake looks like a stale session (transport connected,
495498
// features pending), so resetting here would drop the session the
@@ -523,7 +526,15 @@ class TrezorRepo @Inject constructor(
523526
val idMatch = knownDevices.firstNotNullOfOrNull { known ->
524527
scannedDevices.find { it.id == known.id }
525528
}
526-
val match = idMatch ?: usbDevice ?: throw AppError("No known device found nearby")
529+
// Prefer the transport that just came back, so e.g. a USB replug does
530+
// not reconnect over BLE when the same device is known on both.
531+
val preferredMatch = preferredTransport?.let { preferred ->
532+
scannedDevices.find {
533+
it.id in knownIds && it.transportType.toTransportType() == preferred
534+
}
535+
}
536+
val match = preferredMatch ?: idMatch ?: usbDevice
537+
?: throw AppError("No known device found nearby")
527538
connect(match.id).getOrThrow()
528539
}
529540
}.onSuccess {
@@ -679,7 +690,7 @@ class TrezorRepo @Inject constructor(
679690
*/
680691
private fun observeTransportRestored() {
681692
trezorTransport.transportRestored.onEach {
682-
launchTransportReconnect()
693+
launchTransportReconnect(it)
683694
}.launchIn(scope)
684695
}
685696

@@ -688,32 +699,32 @@ class TrezorRepo @Inject constructor(
688699
* e.g. the USB attach intent the OS app picker routes to the activity (attach is
689700
* not broadcast to receivers, unlike detach).
690701
*/
691-
fun onTransportRestored() = launchTransportReconnect()
702+
fun onTransportRestored(transportType: TransportType) = launchTransportReconnect(transportType)
692703

693704
/**
694705
* Serializes reconnect triggers into one in-flight retry loop. A Trezor
695706
* re-enumerates USB during its unlock flow, so a single replug delivers several
696707
* attach intents; letting each spawn its own loop staggers connect attempts for
697708
* many seconds, and every attempt restarts the device's PIN entry.
698709
*/
699-
private fun launchTransportReconnect() {
710+
private fun launchTransportReconnect(transportType: TransportType) {
700711
if (transportReconnectJob?.isActive == true) return
701-
transportReconnectJob = scope.launch { retryAutoReconnect() }
712+
transportReconnectJob = scope.launch { retryAutoReconnect(transportType) }
702713
}
703714

704715
/**
705716
* A device is often not discoverable right after its transport returns (a BLE
706717
* Trezor takes a few seconds to advertise again), so retry the silent reconnect
707718
* with growing delays instead of giving up on the first empty scan.
708719
*/
709-
private suspend fun retryAutoReconnect() {
720+
private suspend fun retryAutoReconnect(transportType: TransportType) {
710721
repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt ->
711722
if (_state.value.connected != null || isConnectInProgress()) return
712723
delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1))
713724
// A connect may have started while this attempt was waiting.
714725
if (_state.value.connected != null || isConnectInProgress()) return
715726
Logger.info("Attempting auto-reconnect after transport restored, attempt '${attempt + 1}'", context = TAG)
716-
if (autoReconnect().isSuccess) return
727+
if (autoReconnect(preferredTransport = transportType).isSuccess) return
717728
}
718729
}
719730

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.StateFlow
4242
import kotlinx.coroutines.flow.update
4343
import to.bitkit.ext.bluetoothManager
4444
import to.bitkit.ext.usbManager
45+
import to.bitkit.models.TransportType
4546
import to.bitkit.utils.Logger
4647
import java.io.File
4748
import java.util.UUID
@@ -119,10 +120,10 @@ class TrezorTransport @Inject constructor(
119120
private val _externalDisconnect = MutableSharedFlow<String>(extraBufferCapacity = 1)
120121
val externalDisconnect: SharedFlow<String> = _externalDisconnect
121122

122-
private val _transportRestored = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
123+
private val _transportRestored = MutableSharedFlow<TransportType>(extraBufferCapacity = 1)
123124

124-
/** Emits when a transport becomes available again: Bluetooth back on or a Trezor plugged in. */
125-
val transportRestored: SharedFlow<Unit> = _transportRestored
125+
/** Emits the transport that became available again: Bluetooth back on or a Trezor plugged in. */
126+
val transportRestored: SharedFlow<TransportType> = _transportRestored
126127

127128
@Volatile
128129
private var espMigrated = false
@@ -175,12 +176,12 @@ class TrezorTransport @Inject constructor(
175176
emitExternalDisconnect(path)
176177
}
177178
},
178-
onBluetoothOn = { _transportRestored.tryEmit(Unit) },
179+
onBluetoothOn = { _transportRestored.tryEmit(TransportType.BLUETOOTH) },
179180
onUsbDetached = { path ->
180181
if (path in usbConnections.keys) emitExternalDisconnect(path)
181182
},
182183
onUsbAttached = { device ->
183-
if (isTrezorDevice(device)) _transportRestored.tryEmit(Unit)
184+
if (isTrezorDevice(device)) _transportRestored.tryEmit(TransportType.USB)
184185
},
185186
)
186187

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,12 @@ import to.bitkit.ui.sheets.BTCPayConnectionSheet
177177
import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet
178178
import to.bitkit.ui.sheets.BackupRoute
179179
import to.bitkit.ui.sheets.BackupSheet
180-
import to.bitkit.ui.sheets.HardwareSheet
181180
import to.bitkit.ui.sheets.ChangePinSheet
182181
import to.bitkit.ui.sheets.ConnectionClosedSheet
183182
import to.bitkit.ui.sheets.DisablePinSheet
184183
import to.bitkit.ui.sheets.ForceTransferSheet
185184
import to.bitkit.ui.sheets.GiftSheet
185+
import to.bitkit.ui.sheets.HardwareSheet
186186
import to.bitkit.ui.sheets.HighBalanceWarningSheet
187187
import to.bitkit.ui.sheets.LnurlAuthSheet
188188
import to.bitkit.ui.sheets.PinSheet

app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,11 @@ import to.bitkit.ext.rawId
116116
import to.bitkit.models.ActivityBannerType
117117
import to.bitkit.models.BalanceState
118118
import to.bitkit.models.BannerItem
119-
import to.bitkit.models.TransportType
120119
import to.bitkit.models.HwWallet
121120
import to.bitkit.models.MoneyType
122121
import to.bitkit.models.Suggestion
123122
import to.bitkit.models.Toast
123+
import to.bitkit.models.TransportType
124124
import to.bitkit.models.WidgetSize
125125
import to.bitkit.models.WidgetType
126126
import to.bitkit.models.WidgetWithPosition

app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp
4343
import androidx.core.net.toUri
4444
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4545
import androidx.navigation.NavController
46-
import java.time.LocalDate
4746
import to.bitkit.BuildConfig
4847
import to.bitkit.R
4948
import to.bitkit.env.Env
@@ -65,6 +64,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha
6564
import to.bitkit.ui.shared.util.shareText
6665
import to.bitkit.ui.theme.AppThemeSurface
6766
import to.bitkit.ui.theme.Colors
67+
import java.time.LocalDate
6868

6969
private const val DEV_MODE_TAP_THRESHOLD = 5
7070
private const val COPYRIGHT_YEAR_PLACEHOLDER = "{year}"

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import to.bitkit.models.Suggestion
110110
import to.bitkit.models.Toast
111111
import to.bitkit.models.TransactionSpeed
112112
import to.bitkit.models.TransferType
113+
import to.bitkit.models.TransportType
113114
import to.bitkit.models.msatFloorOf
114115
import to.bitkit.models.safe
115116
import to.bitkit.models.sanitizedDeeplinkLogValue
@@ -3058,7 +3059,7 @@ class AppViewModel @Inject constructor(
30583059
}
30593060
}
30603061

3061-
fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored()
3062+
fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored(TransportType.USB)
30623063

30633064
fun submitPairingCode(code: String) = hwWalletRepo.submitPairingCode(code)
30643065

app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,9 @@ class HwWalletRepoTest : BaseUnitTest() {
332332
fun `forwards transport restored to the trezor repo`() = test {
333333
val sut = createRepo()
334334

335-
sut.onTransportRestored()
335+
sut.onTransportRestored(TransportType.USB)
336336

337-
verify(trezorRepo).onTransportRestored()
337+
verify(trezorRepo).onTransportRestored(TransportType.USB)
338338
}
339339

340340
@Test

app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import kotlin.time.Clock
4141
import kotlin.time.ExperimentalTime
4242

4343
@OptIn(ExperimentalTime::class)
44+
@Suppress("LargeClass")
4445
class TrezorRepoTest : BaseUnitTest() {
4546

4647
companion object Fixtures {
@@ -120,17 +121,19 @@ class TrezorRepoTest : BaseUnitTest() {
120121
on { this.model }.thenReturn(model)
121122
}
122123

124+
@Suppress("LongParameterList")
123125
private fun mockKnownDevice(
124126
id: String = DEVICE_ID,
125127
name: String? = DEVICE_NAME,
126128
path: String = DEVICE_PATH,
127129
label: String? = DEVICE_LABEL,
128130
model: String? = DEVICE_MODEL,
131+
transportType: TransportType = TransportType.USB,
129132
) = KnownDevice(
130133
id = id,
131134
name = name,
132135
path = path,
133-
transportType = TransportType.USB,
136+
transportType = transportType,
134137
label = label,
135138
model = model,
136139
lastConnectedAt = 123L,
@@ -214,7 +217,7 @@ class TrezorRepoTest : BaseUnitTest() {
214217

215218
@Test
216219
fun `transport restored auto-reconnects to a known device`() = test {
217-
val transportRestored = MutableSharedFlow<Unit>()
220+
val transportRestored = MutableSharedFlow<TransportType>()
218221
val features = mockFeatures()
219222
val device = mockDeviceInfo()
220223
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
@@ -224,15 +227,15 @@ class TrezorRepoTest : BaseUnitTest() {
224227
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
225228
sut = createSut()
226229

227-
transportRestored.emit(Unit)
230+
transportRestored.emit(TransportType.USB)
228231
advanceUntilIdle()
229232

230233
assertNotNull(sut.state.value.connected)
231234
}
232235

233236
@Test
234237
fun `transport restored retries reconnect until the device is discoverable`() = test {
235-
val transportRestored = MutableSharedFlow<Unit>()
238+
val transportRestored = MutableSharedFlow<TransportType>()
236239
val features = mockFeatures()
237240
val device = mockDeviceInfo()
238241
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
@@ -243,13 +246,36 @@ class TrezorRepoTest : BaseUnitTest() {
243246
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
244247
sut = createSut()
245248

246-
transportRestored.emit(Unit)
249+
transportRestored.emit(TransportType.USB)
247250
advanceUntilIdle()
248251

249252
assertNotNull(sut.state.value.connected)
250253
verify(trezorService, times(2)).scan()
251254
}
252255

256+
@Test
257+
fun `reconnect prefers the transport that came back`() = test {
258+
val features = mockFeatures()
259+
val bleDevice = mockDeviceInfo(id = "ble-1", transportType = TrezorTransportType.BLUETOOTH, path = "ble-path")
260+
val usbDevice = mockDeviceInfo(id = "usb-1", transportType = TrezorTransportType.USB, path = "usb-path")
261+
whenever(hwWalletStore.loadKnownDevices()).thenReturn(
262+
listOf(
263+
mockKnownDevice(id = "ble-1", transportType = TransportType.BLUETOOTH),
264+
mockKnownDevice(id = "usb-1"),
265+
),
266+
)
267+
whenever(trezorService.isConnected()).thenReturn(false)
268+
whenever(trezorService.scan()).thenReturn(listOf(bleDevice, usbDevice))
269+
whenever(trezorService.connect(eq("usb-1"), any())).thenReturn(features)
270+
sut = createSut()
271+
272+
sut.onTransportRestored(TransportType.USB)
273+
advanceUntilIdle()
274+
275+
verify(trezorService).connect(eq("usb-1"), any())
276+
verify(trezorService, never()).connect(eq("ble-1"), any())
277+
}
278+
253279
@Test
254280
fun `repeated transport restored triggers run a single reconnect`() = test {
255281
val features = mockFeatures()
@@ -260,7 +286,7 @@ class TrezorRepoTest : BaseUnitTest() {
260286
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
261287
sut = createSut()
262288

263-
repeat(3) { sut.onTransportRestored() }
289+
repeat(3) { sut.onTransportRestored(TransportType.USB) }
264290
advanceUntilIdle()
265291

266292
assertNotNull(sut.state.value.connected)
@@ -283,13 +309,13 @@ class TrezorRepoTest : BaseUnitTest() {
283309

284310
@Test
285311
fun `transport restored skips reconnect while device awaits pairing code`() = test {
286-
val transportRestored = MutableSharedFlow<Unit>()
312+
val transportRestored = MutableSharedFlow<TransportType>()
287313
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
288314
whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(true))
289315
whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice()))
290316
sut = createSut()
291317

292-
transportRestored.emit(Unit)
318+
transportRestored.emit(TransportType.USB)
293319
advanceUntilIdle()
294320

295321
verify(trezorService, never()).disconnect()
@@ -306,7 +332,7 @@ class TrezorRepoTest : BaseUnitTest() {
306332
whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features)
307333
sut = createSut()
308334

309-
sut.onTransportRestored()
335+
sut.onTransportRestored(TransportType.USB)
310336
advanceUntilIdle()
311337

312338
assertNotNull(sut.state.value.connected)
@@ -333,7 +359,7 @@ class TrezorRepoTest : BaseUnitTest() {
333359

334360
@Test
335361
fun `transport restored does not reconnect when a device is already connected`() = test {
336-
val transportRestored = MutableSharedFlow<Unit>()
362+
val transportRestored = MutableSharedFlow<TransportType>()
337363
val features = mockFeatures()
338364
val device = mockDeviceInfo()
339365
whenever(trezorTransport.transportRestored).thenReturn(transportRestored)
@@ -343,7 +369,7 @@ class TrezorRepoTest : BaseUnitTest() {
343369
sut.scan()
344370
sut.connect(DEVICE_ID)
345371

346-
transportRestored.emit(Unit)
372+
transportRestored.emit(TransportType.USB)
347373
advanceUntilIdle()
348374

349375
verify(trezorService, times(1)).scan()

app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import to.bitkit.models.PubkyProfile
4242
import to.bitkit.models.SamRockPaymentMethod
4343
import to.bitkit.models.SamRockSetupRequest
4444
import to.bitkit.models.TransactionSpeed
45+
import to.bitkit.models.TransportType
4546
import to.bitkit.repositories.ActivityRepo
4647
import to.bitkit.repositories.BackupRepo
4748
import to.bitkit.repositories.BlocktankRepo
@@ -255,7 +256,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
255256
fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test {
256257
sut.onUsbDeviceAttached()
257258

258-
verify(hwWalletRepo).onTransportRestored()
259+
verify(hwWalletRepo).onTransportRestored(TransportType.USB)
259260
}
260261

261262
@Test

0 commit comments

Comments
 (0)