Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bc967a0
feat: add hardware connect view model
ovitrif Jun 18, 2026
b4c9c5a
feat: add hardware connect flow screens
ovitrif Jun 18, 2026
524fb44
feat: wire hardware connect flow
ovitrif Jun 18, 2026
d26ed5c
test: cover hardware connect flow
ovitrif Jun 18, 2026
dc835d7
test: add hardware connect journeys
ovitrif Jun 18, 2026
550c10c
fix: match figma searching animation
ovitrif Jun 18, 2026
a4119ee
fix: use figma loading animation timing
ovitrif Jun 18, 2026
8ee4537
fix: use figma paired coins illustration
ovitrif Jun 18, 2026
a614c53
chore: rename changelog fragment
ovitrif Jun 18, 2026
53f4ee4
fix: animate both searching loader rings
ovitrif Jun 18, 2026
4352f69
fix: bound hardware sheet preview height
ovitrif Jun 18, 2026
1d04f24
fix: use default size in hw sheet previews
ovitrif Jun 18, 2026
9988c92
docs: add sheet preview and figma rules
ovitrif Jun 18, 2026
6799a00
fix: initialize trezor before scan
piotr-iohk Jun 19, 2026
4fec436
fix: cap hardware wallet labels
piotr-iohk Jun 19, 2026
8f2a1d3
fix: gate trezor setup before scan
ovitrif Jun 19, 2026
190468e
docs: refine in-sheet preview rule
ovitrif Jun 19, 2026
07f43cb
fix: match figma paired coins layout
ovitrif Jun 19, 2026
fea7843
fix: use pascalcase hw test tags
ovitrif Jun 19, 2026
c9d76a8
docs: require pascalcase test tags
ovitrif Jun 19, 2026
8b2feaf
fix: request ble permission before hw search
ovitrif Jun 19, 2026
1df5850
chore: lint and cleanup
ovitrif Jun 19, 2026
b4fefce
refactor: reuse shared hw image size ratio
ovitrif Jun 19, 2026
158d92e
fix: handle ble permission deny and bt off
ovitrif Jun 19, 2026
6985b26
docs: use kdoc for hw constants
ovitrif Jun 19, 2026
2c273f0
docs: require kdoc for constants
ovitrif Jun 19, 2026
29611f1
chore: use kdoc for hw constants
ovitrif Jun 19, 2026
d6ef29e
feat: show spinner on hw pair code submit
ovitrif Jun 19, 2026
cedada8
chore: clean up hw sheet layout
ovitrif Jun 19, 2026
0bc4659
feat: improve usb hw connect
ovitrif Jun 19, 2026
5d54e52
refactor: extract pin input
ovitrif Jun 19, 2026
ee99421
fix: polish hw sheet interactions
ovitrif Jun 19, 2026
f3e3e6a
fix: surface hw connect failures
ovitrif Jun 19, 2026
d35810d
fix: harden hw pairing flow
ovitrif Jun 19, 2026
7c3b032
docs: refresh hw journeys
ovitrif Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
- ALWAYS ensure a method exist before calling it
- ALWAYS remove unused code after refactors
- ALWAYS follow Material3 design guidelines for UI components
- When building from a Figma frame, reuse only scaffolding (sheet host, `SheetTopBar`, buttons, typography); NEVER swap a design-specific illustration/animation for a lookalike. Export the frame's assets via the Figma MCP and read animation timing/easing/direction from prototype reactions (`use_figma` → `node.reactions`)
- ALWAYS ensure proper error handling in coroutines
- ALWAYS acknowledge datastore async operations run synchronously in a suspend context
- NEVER use `runBlocking` in suspend functions
Expand Down Expand Up @@ -215,11 +216,14 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
- USE `docs/` as target dir of saved files when asked to create documentation for new features
- NEVER write code in the documentation files
- NEVER add code comments to private functions, classes, etc
- ALWAYS use `/** */` to document constants
- ALWAYS use `_uiState.update { }`, NEVER use `_stateFlow.value =`
- ALWAYS add the warranted changes in unit tests to keep the unit tests succeeding
- ALWAYS follow the patterns of the existing code in `app/src/test` when writing new unit tests
- ALWAYS be mindful of thread safety when working with mutable lists & state
- ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks `Content()`
- ALWAYS preview an in-sheet screen as `BottomSheetPreview { Content(modifier = Modifier.sheetHeight()) }`, passing the host's `SheetSize` when it isn't the default `LARGE`; see `SendErrorScreen.kt`
- ALWAYS write Compose `testTag`s in PascalCase (e.g. `HwPairedFinish`), never snake_case
- ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense
- ALWAYS use `whenever { mock.suspendCall() }` for suspend stubs if not inside `test{}` fn blocks
- ALWAYS use `whenever(mock.call())` for non-suspend stubs and for suspend stubs if inside `test{}` fn blocks
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/ext/Context.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ val Context.usbManager: UsbManager
val Context.bluetoothManager: BluetoothManager
get() = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager

val Context.isBluetoothEnabled: Boolean
get() = bluetoothManager.adapter?.isEnabled == true

val Context.powerManager: PowerManager
get() = getSystemService(Context.POWER_SERVICE) as PowerManager

Expand Down
56 changes: 48 additions & 8 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.HistoryTransaction
import com.synonym.bitkitcore.OnchainActivity
import com.synonym.bitkitcore.PaymentType
import com.synonym.bitkitcore.TrezorDeviceInfo
import com.synonym.bitkitcore.TrezorFeatures
import com.synonym.bitkitcore.TxDirection
import com.synonym.bitkitcore.WatcherEvent
import kotlinx.collections.immutable.ImmutableList
Expand Down Expand Up @@ -70,6 +72,7 @@ class HwWalletRepo @Inject constructor(
private const val TAG = "HwWalletRepo"
private const val WATCHER_ID_SEPARATOR = "|"
private val WATCHER_START_RETRY_DELAY = 30.seconds
const val DEVICE_LABEL_MAX_LENGTH = 50
}

private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
Expand Down Expand Up @@ -111,6 +114,40 @@ class HwWalletRepo @Inject constructor(

fun cancelPairingCode() = trezorRepo.cancelPairingCode()

/** Device discovery and connection state used by the Connect Hardware flow. */
val deviceState: StateFlow<TrezorState> = trezorRepo.state

/** Scans for nearby unpaired devices; results land in [deviceState]'s nearbyDevices. */
suspend fun scan(
includeBluetooth: Boolean = true,
): Result<List<TrezorDeviceInfo>> = trezorRepo.scan(
includeBluetooth = includeBluetooth,
)

suspend fun hasKnownDevice(deviceId: String): Boolean = trezorRepo.hasKnownDevice(deviceId)

/** Connects and pairs a discovered device, persisting it as a watch-only known device. */
suspend fun connect(deviceId: String): Result<TrezorFeatures> {
trezorRepo.resetWalletSelection()
return trezorRepo.connect(deviceId)
}

/**
* Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the
* same wallet identity so the same device paired over both transports renames consistently.
*/
suspend fun setDeviceLabel(deviceId: String, label: String): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val devices = hwWalletStore.loadKnownDevices()
val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" }
val customLabel = label.trim().take(DEVICE_LABEL_MAX_LENGTH).ifEmpty { null }
val updated = devices.map {
if (it.walletKey == target.walletKey) it.copy(customLabel = customLabel) else it
}
hwWalletStore.saveKnownDevices(updated)
Comment thread
ovitrif marked this conversation as resolved.
}
}

/**
* Removes a paired hardware wallet: stops its watchers and forgets every device entry
* that tracks the same wallet. The same physical device paired over both bluetooth and
Expand Down Expand Up @@ -405,16 +442,19 @@ private val KnownDevice.walletKey: String
get() = xpubs.values.sorted().joinToString().ifEmpty { id }

/**
* The label is the user-set name stored on the device itself; without one (or with the
* factory default that just mirrors the model), fall back to the vendor-prefixed model
* (e.g. "Safe 7" reads as "Trezor Safe 7").
* 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").
*/
fun resolveHwWalletName(label: String?, model: String?, customLabel: String? = null): String {
customLabel?.takeIf { it.isNotBlank() }?.let { return it }
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() {
label?.takeIf { it != model }?.let { return it }
val model = model ?: return "Trezor"
return if (model.startsWith("Trezor")) model else "Trezor $model"
}
get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel)

private data class HwWatcherData(
val deviceId: String,
Expand Down
91 changes: 66 additions & 25 deletions app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
Expand All @@ -43,6 +44,8 @@ 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.serialization.Serializable
import to.bitkit.data.HwWalletStore
Expand Down Expand Up @@ -96,6 +99,8 @@ class TrezorRepo @Inject constructor(
val state = _state.asStateFlow()

private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
private var isSetup = CompletableDeferred<Unit>()
private val setupMutex = Mutex()

@Volatile
private var transportReconnectJob: Job? = null
Expand Down Expand Up @@ -162,6 +167,7 @@ class TrezorRepo @Inject constructor(
}

suspend fun resetState() = withContext(ioDispatcher) {
resetSetup()
transportReconnectJob?.cancel()
transportReconnectJob = null

Expand All @@ -184,6 +190,7 @@ class TrezorRepo @Inject constructor(
hwWalletStore.reset()
_state.update {
it.copy(
isInitialized = false,
isScanning = false,
isConnecting = false,
isAutoReconnecting = false,
Expand Down Expand Up @@ -230,22 +237,36 @@ class TrezorRepo @Inject constructor(
}

suspend fun initialize(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json"
Logger.debug("Initializing Trezor with credential path: '$credentialPath'", context = TAG)
trezorService.initialize(credentialPath)
val known = loadKnownDevices()
_state.update { it.copy(isInitialized = true, knownDevices = known.toImmutableList(), error = null) }
}.onFailure { e ->
Logger.error("Trezor init failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
setupMutex.withLock {
if (isSetup.isCancelled) {
isSetup = CompletableDeferred()
}
if (isSetup.isCompleted) {
if (runCatching { isSetup.await() }.isSuccess) return@withLock Result.success(Unit)
isSetup = CompletableDeferred()
}

runCatching {
val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json"
Logger.debug("Initializing Trezor with credential path: '$credentialPath'", context = TAG)
trezorService.initialize(credentialPath)
val known = loadKnownDevices()
_state.update { it.copy(isInitialized = true, knownDevices = known.toImmutableList(), error = null) }
isSetup.complete(Unit)
Unit
}.onFailure { e ->
isSetup.completeExceptionally(e)
Logger.error("Trezor init failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
}
}
}

suspend fun scan(): Result<List<TrezorDeviceInfo>> = withContext(ioDispatcher) {
suspend fun scan(includeBluetooth: Boolean = true): Result<List<TrezorDeviceInfo>> = withContext(ioDispatcher) {
runCatching {
ensureInitialized()
_state.update { it.copy(isScanning = true, error = null) }
val devices = trezorService.scan()
val devices = trezorService.scan(includeBluetooth = includeBluetooth)
val knownIds = _state.value.knownDevices.map { it.id }.toSet()
val nearby = devices.filter { it.id !in knownIds }
_state.update { it.copy(isScanning = false, nearbyDevices = nearby.toImmutableList()) }
Expand All @@ -258,6 +279,7 @@ class TrezorRepo @Inject constructor(

suspend fun listDevices(): Result<List<TrezorDeviceInfo>> = withContext(ioDispatcher) {
runCatching {
ensureInitialized()
val devices = trezorService.listDevices()
val knownIds = _state.value.knownDevices.map { it.id }.toSet()
val nearby = devices.filter { it.id !in knownIds }
Expand All @@ -274,6 +296,7 @@ class TrezorRepo @Inject constructor(
requestUsbPermission: Boolean = true,
): Result<TrezorFeatures> = withContext(ioDispatcher) {
runCatching {
ensureInitialized()
_state.update { it.copy(isConnecting = true, error = null) }
TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId")
val features = connectWithThpRetry(
Expand Down Expand Up @@ -532,6 +555,11 @@ class TrezorRepo @Inject constructor(

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

suspend fun hasKnownDevice(deviceId: String): Boolean = withContext(ioDispatcher) {
_state.value.knownDevices.any { it.matches(deviceId) } ||
loadKnownDevices().any { it.matches(deviceId) }
}

suspend fun autoReconnect(
walletIndex: Int = 0,
preferredTransport: TransportType? = null,
Expand All @@ -549,9 +577,7 @@ class TrezorRepo @Inject constructor(

_state.update { it.copy(isAutoReconnecting = true, error = null) }
runCatching {
if (!_state.value.isInitialized) {
initialize(walletIndex).getOrThrow()
}
ensureInitialized(walletIndex)
val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice else null
if (cachedFeatures != null) {
cachedFeatures
Expand Down Expand Up @@ -604,11 +630,10 @@ class TrezorRepo @Inject constructor(
TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===")
TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId")
TrezorDebugLog.log("RECONNECT", "isInitialized=${_state.value.isInitialized}")
if (!_state.value.isInitialized) {
TrezorDebugLog.log("RECONNECT", "Initializing...")
initialize().getOrThrow()
TrezorDebugLog.log("RECONNECT", "Initialized OK")
}
val needsInitialization = !_state.value.isInitialized
if (needsInitialization) TrezorDebugLog.log("RECONNECT", "Initializing...")
ensureInitialized()
if (needsInitialization) TrezorDebugLog.log("RECONNECT", "Initialized OK")
TrezorDebugLog.log("RECONNECT", "Scanning for devices...")
val scannedDevices = trezorService.scan()
TrezorDebugLog.log(
Expand Down Expand Up @@ -803,9 +828,9 @@ class TrezorRepo @Inject constructor(
}

private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) {
val existing = _state.value.knownDevices
val existingIds = existing.map { it.id }.toSet()
val knownDevices = existing + hwWalletStore.loadKnownDevices().filter { it.id !in existingIds }
val stored = hwWalletStore.loadKnownDevices()
val storedIds = stored.map { it.id }.toSet()
val knownDevices = stored + _state.value.knownDevices.filter { it.id !in storedIds }
val previous = knownDevices.find { it.id == deviceInfo.id }
val known = KnownDevice(
id = deviceInfo.id,
Expand All @@ -816,6 +841,7 @@ class TrezorRepo @Inject constructor(
model = features.model ?: deviceInfo.model,
lastConnectedAt = clock.nowMs(),
xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs(),
customLabel = previous?.customLabel,
)
val updated = knownDevices.filter { it.id != known.id } + known
saveKnownDevices(updated)
Expand Down Expand Up @@ -862,16 +888,27 @@ class TrezorRepo @Inject constructor(
val deviceId = _state.value.connectedDeviceId
?: _state.value.knownDevices.firstOrNull()?.id
?: throw AppError("No device to reconnect")
if (!_state.value.isInitialized) {
initialize().getOrThrow()
}
ensureInitialized()
val devices = trezorService.scan()
val device = devices.find { it.id == deviceId }
?: throw AppError("Device not found during reconnect")
val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection())
_state.update { it.copy(connected = ConnectedTrezorDevice(id = deviceId, features = features)) }
}

private suspend fun ensureInitialized(walletIndex: Int = 0) {
if (!_state.value.isInitialized) {
initialize(walletIndex).getOrThrow()
}
}

private suspend fun resetSetup() {
setupMutex.withLock {
isSetup.cancel()
isSetup = CompletableDeferred()
}
}

suspend fun clearCredentials(deviceId: String): Result<Unit> = withContext(ioDispatcher) {
runCatching {
trezorService.clearCredentials(deviceId)
Expand Down Expand Up @@ -979,8 +1016,12 @@ data class KnownDevice(
val lastConnectedAt: Long,
/** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */
val xpubs: Map<String, String> = emptyMap(),
/** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */
val customLabel: String? = null,
)

private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId

private fun TrezorTransportType.toTransportType(): TransportType = when (this) {
TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH
TrezorTransportType.USB -> TransportType.USB
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/java/to/bitkit/services/TrezorService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ class TrezorService @Inject constructor(
}
}

suspend fun scan(): List<TrezorDeviceInfo> {
suspend fun scan(includeBluetooth: Boolean = true): List<TrezorDeviceInfo> {
return ServiceQueue.CORE.background {
trezorScan()
transport.withBluetoothScanningEnabled(includeBluetooth) {
trezorScan()
}
}
}

Expand Down
Loading
Loading