Skip to content

Commit 12c2814

Browse files
committed
feat: add hardware connect view model
1 parent d53f2f9 commit 12c2814

4 files changed

Lines changed: 216 additions & 8 deletions

File tree

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

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import com.synonym.bitkitcore.Activity
44
import com.synonym.bitkitcore.HistoryTransaction
55
import com.synonym.bitkitcore.OnchainActivity
66
import com.synonym.bitkitcore.PaymentType
7+
import com.synonym.bitkitcore.TrezorDeviceInfo
8+
import com.synonym.bitkitcore.TrezorFeatures
79
import com.synonym.bitkitcore.TxDirection
810
import com.synonym.bitkitcore.WatcherEvent
911
import kotlinx.collections.immutable.ImmutableList
@@ -111,6 +113,31 @@ class HwWalletRepo @Inject constructor(
111113

112114
fun cancelPairingCode() = trezorRepo.cancelPairingCode()
113115

116+
/** Device discovery and connection state used by the Connect Hardware flow. */
117+
val deviceState: StateFlow<TrezorState> = trezorRepo.state
118+
119+
/** Scans for nearby unpaired devices over USB/Bluetooth; results land in [deviceState]'s nearbyDevices. */
120+
suspend fun scan(): Result<List<TrezorDeviceInfo>> = trezorRepo.scan()
121+
122+
/** Connects and pairs a discovered device, persisting it as a watch-only known device. */
123+
suspend fun connect(deviceId: String): Result<TrezorFeatures> = trezorRepo.connect(deviceId)
124+
125+
/**
126+
* Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the
127+
* same wallet identity so the same device paired over both transports renames consistently.
128+
*/
129+
suspend fun setDeviceLabel(deviceId: String, label: String): Result<Unit> = withContext(ioDispatcher) {
130+
runCatching {
131+
val devices = hwWalletStore.loadKnownDevices()
132+
val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" }
133+
val customLabel = label.trim().ifEmpty { null }
134+
val updated = devices.map {
135+
if (it.walletKey == target.walletKey) it.copy(customLabel = customLabel) else it
136+
}
137+
hwWalletStore.saveKnownDevices(updated)
138+
}
139+
}
140+
114141
/**
115142
* Removes a paired hardware wallet: stops its watchers and forgets every device entry
116143
* that tracks the same wallet. The same physical device paired over both bluetooth and
@@ -405,16 +432,19 @@ private val KnownDevice.walletKey: String
405432
get() = xpubs.values.sorted().joinToString().ifEmpty { id }
406433

407434
/**
408-
* The label is the user-set name stored on the device itself; without one (or with the
409-
* factory default that just mirrors the model), fall back to the vendor-prefixed model
410-
* (e.g. "Safe 7" reads as "Trezor Safe 7").
435+
* Resolves the name shown for a hardware wallet: the Bitkit-side custom label if the user set one,
436+
* otherwise the device's own label; without one (or with the factory default that just mirrors the
437+
* model) it falls back to the vendor-prefixed model (e.g. "Safe 7" reads as "Trezor Safe 7").
411438
*/
439+
fun resolveHwWalletName(label: String?, model: String?, customLabel: String? = null): String {
440+
customLabel?.takeIf { it.isNotBlank() }?.let { return it }
441+
label?.takeIf { it != model }?.let { return it }
442+
val resolvedModel = model ?: return "Trezor"
443+
return if (resolvedModel.startsWith("Trezor")) resolvedModel else "Trezor $resolvedModel"
444+
}
445+
412446
private val KnownDevice.displayName: String
413-
get() {
414-
label?.takeIf { it != model }?.let { return it }
415-
val model = model ?: return "Trezor"
416-
return if (model.startsWith("Trezor")) model else "Trezor $model"
417-
}
447+
get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel)
418448

419449
private data class HwWatcherData(
420450
val deviceId: String,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,7 @@ class TrezorRepo @Inject constructor(
816816
model = features.model ?: deviceInfo.model,
817817
lastConnectedAt = clock.nowMs(),
818818
xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs(),
819+
customLabel = previous?.customLabel,
819820
)
820821
val updated = knownDevices.filter { it.id != known.id } + known
821822
saveKnownDevices(updated)
@@ -979,6 +980,8 @@ data class KnownDevice(
979980
val lastConnectedAt: Long,
980981
/** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */
981982
val xpubs: Map<String, String> = emptyMap(),
983+
/** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */
984+
val customLabel: String? = null,
982985
)
983986

984987
private fun TrezorTransportType.toTransportType(): TransportType = when (this) {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package to.bitkit.ui.sheets.hardware
2+
3+
import androidx.compose.runtime.Immutable
4+
import androidx.lifecycle.ViewModel
5+
import androidx.lifecycle.viewModelScope
6+
import com.synonym.bitkitcore.TrezorFeatures
7+
import dagger.hilt.android.lifecycle.HiltViewModel
8+
import kotlinx.coroutines.Job
9+
import kotlinx.coroutines.delay
10+
import kotlinx.coroutines.flow.MutableSharedFlow
11+
import kotlinx.coroutines.flow.MutableStateFlow
12+
import kotlinx.coroutines.flow.asSharedFlow
13+
import kotlinx.coroutines.flow.asStateFlow
14+
import kotlinx.coroutines.flow.update
15+
import kotlinx.coroutines.isActive
16+
import kotlinx.coroutines.launch
17+
import to.bitkit.repositories.HwWalletRepo
18+
import to.bitkit.repositories.resolveHwWalletName
19+
import javax.inject.Inject
20+
import kotlin.time.Duration.Companion.seconds
21+
22+
/**
23+
* Backs the Connect Hardware bottom-sheet flow (Intro -> Searching -> Found -> Paired). Drives
24+
* device discovery, connection and the Bitkit-side funds label through [HwWalletRepo], emitting
25+
* [HwConnectEffect]s that the sheet collects to navigate its inner [HardwareRoute] graph. The
26+
* one-time pairing code, when the device requests it during connect, is surfaced inline by
27+
* navigating to [HardwareRoute.PairCode].
28+
*/
29+
@HiltViewModel
30+
class HwConnectViewModel @Inject constructor(
31+
private val hwWalletRepo: HwWalletRepo,
32+
) : ViewModel() {
33+
companion object {
34+
private val SCAN_INTERVAL = 2.seconds
35+
}
36+
37+
private val _uiState = MutableStateFlow(HwConnectUiState())
38+
val uiState = _uiState.asStateFlow()
39+
40+
private val _effects = MutableSharedFlow<HwConnectEffect>(extraBufferCapacity = 1)
41+
val effects = _effects.asSharedFlow()
42+
43+
private var searchJob: Job? = null
44+
private var labelInitialized = false
45+
46+
init {
47+
observePairingCode()
48+
observeConnectedWallet()
49+
}
50+
51+
fun onIntroContinue() {
52+
setEffect(HwConnectEffect.NavigateToSearching)
53+
startSearching()
54+
}
55+
56+
fun onConnectClick() {
57+
val deviceId = _uiState.value.foundDeviceId ?: return
58+
searchJob?.cancel()
59+
_uiState.update { it.copy(isConnecting = true) }
60+
viewModelScope.launch {
61+
hwWalletRepo.connect(deviceId)
62+
.onSuccess { onConnected(deviceId, it) }
63+
.onFailure { _uiState.update { state -> state.copy(isConnecting = false) } }
64+
}
65+
}
66+
67+
fun onLabelChange(value: String) = _uiState.update { it.copy(labelInput = value) }
68+
69+
fun onFinishClick() {
70+
val deviceId = _uiState.value.pairedDeviceId
71+
if (deviceId == null) {
72+
setEffect(HwConnectEffect.Dismiss)
73+
return
74+
}
75+
viewModelScope.launch {
76+
hwWalletRepo.setDeviceLabel(deviceId, _uiState.value.labelInput)
77+
setEffect(HwConnectEffect.Dismiss)
78+
}
79+
}
80+
81+
fun resetState() {
82+
searchJob?.cancel()
83+
searchJob = null
84+
labelInitialized = false
85+
_uiState.update { HwConnectUiState() }
86+
}
87+
88+
private fun startSearching() {
89+
if (searchJob?.isActive == true) return
90+
_uiState.update { it.copy(isSearching = true) }
91+
searchJob = viewModelScope.launch {
92+
while (isActive) {
93+
hwWalletRepo.scan()
94+
val device = hwWalletRepo.deviceState.value.nearbyDevices.firstOrNull()
95+
if (device != null) {
96+
_uiState.update {
97+
it.copy(
98+
isSearching = false,
99+
foundDeviceId = device.id,
100+
deviceModel = resolveHwWalletName(label = null, model = device.model),
101+
)
102+
}
103+
setEffect(HwConnectEffect.NavigateToFound)
104+
return@launch
105+
}
106+
delay(SCAN_INTERVAL)
107+
}
108+
}
109+
}
110+
111+
private fun onConnected(deviceId: String, features: TrezorFeatures) {
112+
val name = resolveHwWalletName(label = features.label, model = features.model)
113+
_uiState.update {
114+
it.copy(
115+
isConnecting = false,
116+
pairedDeviceId = deviceId,
117+
deviceName = name,
118+
labelInput = if (labelInitialized) it.labelInput else name,
119+
)
120+
}
121+
labelInitialized = true
122+
setEffect(HwConnectEffect.NavigateToPaired)
123+
}
124+
125+
private fun observePairingCode() {
126+
viewModelScope.launch {
127+
hwWalletRepo.needsPairingCode.collect { needsCode ->
128+
if (needsCode) setEffect(HwConnectEffect.NavigateToPairCode)
129+
}
130+
}
131+
}
132+
133+
private fun observeConnectedWallet() {
134+
viewModelScope.launch {
135+
hwWalletRepo.wallets.collect { wallets ->
136+
val deviceId = _uiState.value.pairedDeviceId ?: return@collect
137+
val wallet = wallets.firstOrNull { deviceId == it.id || deviceId in it.deviceIds } ?: return@collect
138+
_uiState.update {
139+
it.copy(
140+
deviceName = wallet.name,
141+
balanceSats = wallet.balanceSats,
142+
labelInput = if (labelInitialized) it.labelInput else wallet.name,
143+
)
144+
}
145+
labelInitialized = true
146+
}
147+
}
148+
}
149+
150+
private fun setEffect(effect: HwConnectEffect) = viewModelScope.launch { _effects.emit(effect) }
151+
}
152+
153+
@Immutable
154+
data class HwConnectUiState(
155+
val isSearching: Boolean = false,
156+
val isConnecting: Boolean = false,
157+
val foundDeviceId: String? = null,
158+
val pairedDeviceId: String? = null,
159+
val deviceName: String = "",
160+
val deviceModel: String = "",
161+
val balanceSats: ULong = 0uL,
162+
val labelInput: String = "",
163+
)
164+
165+
sealed interface HwConnectEffect {
166+
data object NavigateToSearching : HwConnectEffect
167+
data object NavigateToFound : HwConnectEffect
168+
data object NavigateToPairCode : HwConnectEffect
169+
data object NavigateToPaired : HwConnectEffect
170+
data object Dismiss : HwConnectEffect
171+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3075,6 +3075,10 @@ class AppViewModel @Inject constructor(
30753075
return
30763076
}
30773077

3078+
// The Connect Hardware flow is itself a Hardware sheet and drives the pair-code step
3079+
// inline within its own NavHost; replacing it here would tear down that back stack.
3080+
if (_currentSheet.value is Sheet.Hardware) return
3081+
30783082
isPairingCodeSheetQueued = false
30793083
showSheet(Sheet.Hardware(route = HardwareRoute.PairCode))
30803084
}

0 commit comments

Comments
 (0)