diff --git a/AGENTS.md b/AGENTS.md index ec1a631f61..11c2c06b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,7 @@ suspend fun getData(): Result = 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 @@ -215,11 +216,14 @@ suspend fun getData(): Result = 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 diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index d605abad4b..eac7fa1ac4 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -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 diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index a343270d03..20df16bf90 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -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 @@ -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) @@ -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 = trezorRepo.state + + /** Scans for nearby unpaired devices; results land in [deviceState]'s nearbyDevices. */ + suspend fun scan( + includeBluetooth: Boolean = true, + ): Result> = 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 { + 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 = 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) + } + } + /** * 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 @@ -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, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 4bee084cd7..5d126d2368 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -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 @@ -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 @@ -96,6 +99,8 @@ class TrezorRepo @Inject constructor( val state = _state.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + private var isSetup = CompletableDeferred() + private val setupMutex = Mutex() @Volatile private var transportReconnectJob: Job? = null @@ -162,6 +167,7 @@ class TrezorRepo @Inject constructor( } suspend fun resetState() = withContext(ioDispatcher) { + resetSetup() transportReconnectJob?.cancel() transportReconnectJob = null @@ -184,6 +190,7 @@ class TrezorRepo @Inject constructor( hwWalletStore.reset() _state.update { it.copy( + isInitialized = false, isScanning = false, isConnecting = false, isAutoReconnecting = false, @@ -230,22 +237,36 @@ class TrezorRepo @Inject constructor( } suspend fun initialize(walletIndex: Int = 0): Result = 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> = withContext(ioDispatcher) { + suspend fun scan(includeBluetooth: Boolean = true): Result> = 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()) } @@ -258,6 +279,7 @@ class TrezorRepo @Inject constructor( suspend fun listDevices(): Result> = 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 } @@ -274,6 +296,7 @@ class TrezorRepo @Inject constructor( requestUsbPermission: Boolean = true, ): Result = withContext(ioDispatcher) { runCatching { + ensureInitialized() _state.update { it.copy(isConnecting = true, error = null) } TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") val features = connectWithThpRetry( @@ -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, @@ -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 @@ -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( @@ -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, @@ -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) @@ -862,9 +888,7 @@ 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") @@ -872,6 +896,19 @@ class TrezorRepo @Inject constructor( _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 = withContext(ioDispatcher) { runCatching { trezorService.clearCredentials(deviceId) @@ -979,8 +1016,12 @@ data class KnownDevice( val lastConnectedAt: Long, /** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */ val xpubs: Map = 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 diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 5b5c44292e..8d9e97fe9c 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -85,9 +85,11 @@ class TrezorService @Inject constructor( } } - suspend fun scan(): List { + suspend fun scan(includeBluetooth: Boolean = true): List { return ServiceQueue.CORE.background { - trezorScan() + transport.withBluetoothScanningEnabled(includeBluetooth) { + trezorScan() + } } } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 71d8ab63af..8aacd8a72c 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager import to.bitkit.models.TransportType @@ -120,6 +122,9 @@ class TrezorTransport @Inject constructor( @Volatile private var requestUsbPermissionEnabled = true + @Volatile + private var bluetoothScanningEnabled = true + private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) val externalDisconnect: SharedFlow = _externalDisconnect @@ -202,6 +207,7 @@ class TrezorTransport @Inject constructor( private val bleConnections = ConcurrentHashMap() private val discoveredBleDevices = ConcurrentHashMap() + private val optionScopeMutex = Mutex() private data class UsbOpenDevice( val connection: UsbDeviceConnection, @@ -222,16 +228,32 @@ class TrezorTransport @Inject constructor( @Volatile var writeStatus: Int = BluetoothGatt.GATT_SUCCESS, ) - suspend fun withUsbPermissionRequestsEnabled(enabled: Boolean, block: suspend () -> T): T { + suspend fun withUsbPermissionRequestsEnabled( + enabled: Boolean, + block: suspend () -> T, + ): T = optionScopeMutex.withLock { val previous = requestUsbPermissionEnabled requestUsbPermissionEnabled = enabled - return try { + 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 + } + } + override fun enumerateDevices(): List { val devices = mutableListOf() @@ -254,13 +276,17 @@ class TrezorTransport @Inject constructor( Logger.error("USB enumerate failed", it, context = TAG) } - runCatching { - enumerateBleDevices() - }.onSuccess { - devices.addAll(it) - Logger.debug("BLE enumerate found '${it.size}' Trezor device(s)", context = TAG) - }.onFailure { - Logger.error("BLE enumerate failed", it, context = TAG) + if (bluetoothScanningEnabled) { + runCatching { + enumerateBleDevices() + }.onSuccess { + devices.addAll(it) + Logger.debug("BLE enumerate found '${it.size}' Trezor device(s)", context = TAG) + }.onFailure { + Logger.error("BLE enumerate failed", it, context = TAG) + } + } else { + Logger.debug("Skipped BLE enumerate while Bluetooth scanning is disabled", context = TAG) } val bridgeDevices = bridgeTransport.enumerateDevices() diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 9bd3e5f822..7693d14467 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -2,6 +2,7 @@ package to.bitkit.ui import android.app.NotificationManager import android.content.Intent +import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.os.Bundle import android.os.Looper @@ -17,6 +18,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.core.content.IntentCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -65,6 +67,12 @@ 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 + @AndroidEntryPoint class MainActivity : FragmentActivity() { private companion object { @@ -221,7 +229,7 @@ class MainActivity : FragmentActivity() { private fun handleLaunchIntent(intent: Intent) { if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { - handleUsbAttachIntent() + handleUsbAttachIntent(intent) return } @@ -232,8 +240,18 @@ class MainActivity : FragmentActivity() { * The OS delivers the USB attach event as an activity intent (via the app picker), * not as a broadcast, so it is forwarded from here to trigger the silent reconnect. */ - private fun handleUsbAttachIntent() { - appViewModel.onUsbDeviceAttached() + private fun handleUsbAttachIntent(intent: Intent) { + val device = intent.usbDevice() + if (device == null) { + appViewModel.onUsbDeviceAttached() + return + } + if (!device.isSupportedTrezorDevice()) return + + appViewModel.onUsbDeviceAttached( + deviceId = device.deviceName.takeUnless { device.isTrezorBootloader() }, + deviceModel = getString(R.string.hardware__device_model_trezor), + ) } private fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() @@ -275,11 +293,23 @@ internal fun Intent?.launchKey(): String? { Intent.ACTION_VIEW -> data?.toString()?.let { SamRockSetupRequest.sanitizedLaunchKey(it) ?: it } - UsbManager.ACTION_USB_DEVICE_ATTACHED -> action + UsbManager.ACTION_USB_DEVICE_ATTACHED -> listOfNotNull(action, usbDevice()?.deviceName).joinToString(":") else -> null } } +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/BottomSheet.kt b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt index 2b28e06816..0acc616341 100644 --- a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt @@ -109,9 +109,10 @@ private fun Preview() { .gradientBackground() .padding(horizontal = 16.dp) ) { - SheetTopBar("Sheet Title") + SheetTopBar("Page Title") FillHeight() - PrimaryButton(text = "Primary Button", onClick = {}) + PrimaryButton(text = "Button", onClick = {}) + VerticalSpacer(24.dp) } } } 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 75c268af21..e4c2bec4e6 100644 --- a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt +++ b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt @@ -19,16 +19,26 @@ import to.bitkit.R import to.bitkit.models.TransportType import to.bitkit.ui.theme.Colors -// Device illustration proportions, taken from the Figma hardware wallet frames. -private const val HW_DEVICE_IMAGE_SIZE_RATIO = 256f / 375f +/** + * HwWalletComponents - Reusable components and utilities for the Hardware Wallet UI. + * */ + +/** Illustration width as a fraction of the sheet width — the 256-wide Visual in the 375-wide Figma frame. */ +internal const val HW_ILLUSTRATION_SIZE_RATIO = 256f / 375f + +/** Trezor illustration left bleed past the frame, as a fraction of the sheet width (Figma device frames). */ private const val HW_DEVICE_TREZOR_BLEED_RATIO = 84f / 375f + +/** Ledger illustration right bleed past the frame, as a fraction of the sheet width (Figma device frames). */ 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 @Composable fun HwDeviceIllustrations(modifier: Modifier = Modifier) { BoxWithConstraints(modifier) { - val imageSize = maxWidth * HW_DEVICE_IMAGE_SIZE_RATIO + val imageSize = maxWidth * HW_ILLUSTRATION_SIZE_RATIO val staggerY = maxWidth * HW_DEVICE_STAGGER_RATIO TrezorImage(imageSize = imageSize, staggerY = staggerY) LedgerImage( diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt index eddae8890e..fd3261d819 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -68,6 +68,9 @@ private const val ALPHA_PRESSED = 0.2f private val pressHaptic = HapticFeedbackType.VirtualKey private val errorHaptic = HapticFeedbackType.Reject +/** Pad dimming when disabled — white keys render at White64. */ +private const val DISABLED_ALPHA = 0.64f + /** * Numeric keyboard. */ @@ -79,6 +82,7 @@ fun NumberPad( availableHeight: Dp = defaultHeight, decimalSeparator: String = KEY_DECIMAL, errorKey: String? = null, + enabled: Boolean = true, includeNavigationBarsPadding: Boolean = false, onDeleteLongPress: (() -> Unit)? = null, ) { @@ -96,14 +100,17 @@ fun NumberPad( } else { modifier } + // Disabled: no-op input and dim the keys to White64. + val keyOnPress: (String) -> Unit = if (enabled) onPress else { _ -> } BoxWithConstraints( modifier = safeAreaModifier + .alpha(if (enabled) 1f else DISABLED_ALPHA) .focusRequester(focusRequester) .onPreviewKeyEvent { keyEvent -> if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false val mapped = mapHardwareKey(keyEvent.key, type) ?: return@onPreviewKeyEvent false - onPress(mapped) + keyOnPress(mapped) true } .focusable() @@ -123,7 +130,7 @@ fun NumberPad( items((1..9).map { "$it" }) { number -> NumberPadKeyButton( text = number, - onPress = onPress, + onPress = keyOnPress, height = buttonHeight, hasError = errorKey == number, ) @@ -138,7 +145,7 @@ fun NumberPad( NumberPadType.INTEGER -> NumberPadKeyButton( text = KEY_000, - onPress = onPress, + onPress = keyOnPress, height = buttonHeight, hasError = errorKey == KEY_000, testTag = "N000", @@ -146,7 +153,7 @@ fun NumberPad( NumberPadType.DECIMAL -> NumberPadKeyButton( text = decimalSeparator, - onPress = onPress, + onPress = keyOnPress, height = buttonHeight, key = KEY_DECIMAL, hasError = errorKey == KEY_DECIMAL, @@ -157,14 +164,14 @@ fun NumberPad( item { NumberPadKeyButton( text = "0", - onPress = onPress, + onPress = keyOnPress, height = buttonHeight, hasError = errorKey == "0", ) } item { NumberPadDeleteButton( - onPress = { onPress(KEY_DELETE) }, + onPress = { keyOnPress(KEY_DELETE) }, onLongPress = onDeleteLongPress, height = buttonHeight, modifier = Modifier.testTag("NRemove"), diff --git a/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt b/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt index 1305f90663..0a8adbce78 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt @@ -10,8 +10,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.theme.AppThemeSurface @@ -26,7 +24,6 @@ fun SheetDragHandle( shape = MaterialTheme.shapes.extraLarge, modifier = modifier .padding(top = 12.dp) - .semantics { contentDescription = "Drag handle" } ) { Box(Modifier.size(width = 32.dp, height = 4.dp)) } diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index 9ea91f9378..dbfcbe5403 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -375,6 +375,8 @@ fun Text13Up( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, textAlign: TextAlign = TextAlign.Start, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, ) { Text( text = text.uppercase(), @@ -382,6 +384,8 @@ fun Text13Up( color = color, textAlign = textAlign, ), + maxLines = maxLines, + overflow = overflow, modifier = modifier ) } diff --git a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt index 12afac6869..70020ecac2 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -109,6 +109,8 @@ private fun RowScope.Content( Text13Up( text = title, color = Colors.White64, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false) ) titleTrailing() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 0abf58af0b..cc3ea5f20d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -777,6 +777,7 @@ private fun RowScope.HwDeviceCell( modifier = Modifier .clickableAlpha(onClick = { onClick(wallet.id) }) .padding(vertical = 4.dp) + .padding(end = 8.dp) .testTag("ActivityHardware") ) { HorizontalSpacer(4.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt index fe6b542db2..74b55bd855 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt @@ -74,7 +74,7 @@ fun WidgetsIntroScreen( fullWidth = false, modifier = Modifier .weight(1f) - .testTag("WidgetsOnboarding-view-organize") + .testTag("WidgetsOnboardingViewOrganize") ) PrimaryButton( text = stringResource(R.string.widgets__add), 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 404e4c907e..a724309eb7 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 @@ -1,64 +1,225 @@ package to.bitkit.ui.sheets.hardware +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.serialization.Serializable +import to.bitkit.R +import to.bitkit.ext.isBluetoothEnabled +import to.bitkit.ext.startActivityAppSettings import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.navigateTo +import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.viewmodels.AppViewModel +/** Runtime nearby-devices permissions needed to BLE-scan: SCAN + CONNECT on Android 12+, else fine location. */ +private val bluetoothPermissions: List + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + /** - * Entry point for the hardware-wallet connect flow opened from the home suggestion card, - * and host of the Pair Device screen shown app-wide when the device asks for its one-time pairing code. + * Entry point for the hardware-wallet connect flow opened from the home suggestion card and the + * Hardware Wallets settings Add button. Hosts the four connect steps (Intro -> Searching -> Found + * -> Paired) plus the Pair Device step shown when the device asks for its one-time pairing code. + * Continuing from the intro starts USB discovery immediately; Bluetooth discovery is included once + * the nearby-devices permission is granted and Bluetooth is turned on. */ +@OptIn(ExperimentalPermissionsApi::class) @Composable fun HardwareSheet( sheet: Sheet.Hardware, appViewModel: AppViewModel, + viewModel: HwConnectViewModel = hiltViewModel(), ) { - Content( - sheet = sheet, - onDismiss = appViewModel::hideSheet, - onSubmitPairingCode = appViewModel::submitPairingCode, - onCancelPairingCode = appViewModel::cancelPairingCode, - ) -} - -@Composable -private fun Content( - sheet: Sheet.Hardware, - modifier: Modifier = Modifier, - onDismiss: () -> Unit = {}, - onSubmitPairingCode: (String) -> Unit = {}, - onCancelPairingCode: () -> Unit = {}, -) { + val context = LocalContext.current val navController = rememberNavController() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showBlePermissionDialog by remember { mutableStateOf(false) } + var blePermissionRequested by remember { mutableStateOf(false) } + + val enableBluetoothLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (context.isBluetoothEnabled) viewModel.setBluetoothScanningEnabled() + } + + val enableBleScanning: () -> Unit = { + if (context.isBluetoothEnabled) { + viewModel.setBluetoothScanningEnabled() + } else { + enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + } + val blePermissions = rememberMultiplePermissionsState(bluetoothPermissions) { results -> + if (results.values.all { it }) { + enableBleScanning() + } else { + showBlePermissionDialog = true + } + } + val requestBleAccess: () -> Unit = { + when { + blePermissions.allPermissionsGranted -> enableBleScanning() + blePermissionRequested && !blePermissions.shouldShowRationale -> showBlePermissionDialog = true + else -> { + blePermissionRequested = true + blePermissions.launchMultiplePermissionRequest() + } + } + } + val onIntroContinue: () -> Unit = { + val includeBluetooth = blePermissions.allPermissionsGranted && context.isBluetoothEnabled + viewModel.onIntroContinue(includeBluetooth = includeBluetooth) + if (!includeBluetooth) { + requestBleAccess() + } + } + + DisposableEffect(Unit) { + onDispose { viewModel.resetState() } + } + + ConnectEffectHandler( + viewModel = viewModel, + navController = navController, + appViewModel = appViewModel, + ) Column( - modifier = modifier + modifier = Modifier .fillMaxWidth() .sheetHeight(SheetSize.LARGE) - .testTag("hardware_sheet") + .testTag("HardwareWalletSheet") ) { NavHost( navController = navController, startDestination = sheet.route, ) { composableWithDefaultTransitions { - HwIntroSheet(onDismiss = onDismiss) + HwIntroSheet( + onContinue = onIntroContinue, + onCancel = appViewModel::hideSheet, + ) + } + composableWithDefaultTransitions { + HwSearchingSheet( + errorMessage = uiState.errorMessage, + onCancel = appViewModel::hideSheet, + ) + } + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() + LaunchedEffect(route.deviceId, route.deviceModel) { + viewModel.onFoundRoute( + deviceId = route.deviceId, + deviceModel = route.deviceModel, + ) + } + val deviceModel = uiState.deviceModel.ifBlank { + route.deviceModel.ifBlank { stringResource(R.string.hardware__device_model_trezor) } + } + HwFoundSheet( + deviceModel = deviceModel, + isConnecting = uiState.isConnecting, + errorMessage = uiState.errorMessage, + onConnect = { viewModel.onConnectClick(route.deviceId) }, + onCancel = { + viewModel.cancelConnect() + appViewModel.hideSheet() + }, + ) + } + composableWithDefaultTransitions { + HwPairedSheet( + uiState = uiState, + onLabelChange = viewModel::onLabelChange, + onFinish = viewModel::onFinishClick, + ) } composableWithDefaultTransitions { HwPairCodeSheet( - onSubmit = onSubmitPairingCode, - onCancel = onCancelPairingCode, + onSubmit = appViewModel::submitPairingCode, + onCancel = appViewModel::cancelPairingCode, + ) + } + } + } + + BackHandler { + viewModel.cancelConnect() + appViewModel.hideSheet() + } + + if (showBlePermissionDialog) { + AppAlertDialog( + title = stringResource(R.string.hardware__bluetooth_permission_title), + text = stringResource(R.string.hardware__bluetooth_permission_text), + confirmText = stringResource(R.string.hardware__bluetooth_permission_settings), + onConfirm = { + showBlePermissionDialog = false + context.startActivityAppSettings() + }, + onDismiss = { showBlePermissionDialog = false }, + ) + } +} + +@Composable +private fun ConnectEffectHandler( + viewModel: HwConnectViewModel, + navController: NavHostController, + appViewModel: AppViewModel, +) { + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + HwConnectEffect.NavigateToSearching -> navController.navigateTo(HardwareRoute.Searching) + is HwConnectEffect.NavigateToFound -> navController.navigateTo( + HardwareRoute.Found( + deviceId = effect.deviceId, + deviceModel = effect.deviceModel, + ), ) + HwConnectEffect.NavigateToPairCode -> navController.navigateTo(HardwareRoute.PairCode) + HwConnectEffect.NavigateToPaired -> navController.navigateTo(HardwareRoute.Paired) + HwConnectEffect.Dismiss -> appViewModel.hideSheet() } } } @@ -68,6 +229,18 @@ sealed interface HardwareRoute { @Serializable data object Intro : HardwareRoute + @Serializable + data object Searching : HardwareRoute + + @Serializable + data class Found( + val deviceId: String? = null, + val deviceModel: String = "", + ) : HardwareRoute + + @Serializable + data object Paired : HardwareRoute + @Serializable data object PairCode : HardwareRoute } 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 new file mode 100644 index 0000000000..e6c4b9c345 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -0,0 +1,269 @@ +package to.bitkit.ui.sheets.hardware + +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 +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH +import to.bitkit.repositories.resolveHwWalletName +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +/** + * Backs the Connect Hardware bottom-sheet flow (Intro -> Searching -> Found -> Paired). Drives + * device discovery, connection and the Bitkit-side funds label through [HwWalletRepo], emitting + * [HwConnectEffect]s that the sheet collects to navigate its inner [HardwareRoute] graph. The + * one-time pairing code, when the device requests it during connect, is surfaced inline by + * navigating to [HardwareRoute.PairCode]. + */ +@HiltViewModel +class HwConnectViewModel @Inject constructor( + private val hwWalletRepo: HwWalletRepo, + @ApplicationContext private val context: Context, +) : ViewModel() { + companion object { + /** Delay between scan attempts while searching for a nearby device. */ + private val SCAN_INTERVAL = 2.seconds + } + + private val _uiState = MutableStateFlow(HwConnectUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + private var searchJob: Job? = null + private var connectJob: Job? = null + private var labelInitialized = false + private var includeBluetoothInScan = true + private var scanUsbBeforeConnect = false + + init { + observePairingCode() + observeConnectedWallet() + } + + fun onIntroContinue(includeBluetooth: Boolean = true) { + includeBluetoothInScan = includeBluetooth + _uiState.update { it.copy(errorMessage = null) } + setEffect(HwConnectEffect.NavigateToSearching) + startSearching() + } + + fun setBluetoothScanningEnabled() { + includeBluetoothInScan = true + } + + fun onFoundRoute(deviceId: String?, deviceModel: String) { + if (deviceId == null) return + searchJob?.cancel() + searchJob = null + _uiState.update { + it.copy( + isSearching = false, + foundDeviceId = deviceId, + deviceModel = deviceModel.ifBlank { resolveHwWalletName(label = null, model = null) }, + errorMessage = null, + ) + } + scanUsbBeforeConnect = true + } + + fun onConnectClick(deviceIdOverride: String? = null) { + val state = _uiState.value + val deviceId = deviceIdOverride ?: state.foundDeviceId ?: return + if (connectJob?.isActive == true) return + val shouldScanUsbBeforeConnect = scanUsbBeforeConnect + searchJob?.cancel() + _uiState.update { it.copy(isConnecting = true, errorMessage = null) } + connectJob = viewModelScope.launch { + var resolvedDeviceId = deviceId + var resolvedDeviceModel = state.deviceModel + 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) + _uiState.update { + it.copy( + foundDeviceId = resolvedDeviceId, + deviceModel = resolvedDeviceModel, + ) + } + } + } + .onFailure { + onConnectFailed(resolvedDeviceId, resolvedDeviceModel) + return@launch + } + } + hwWalletRepo.connect(resolvedDeviceId) + .onSuccess { onConnected(resolvedDeviceId, it) } + .onFailure { onConnectFailed(resolvedDeviceId, resolvedDeviceModel) } + connectJob = null + } + } + + private fun onConnectFailed(deviceId: String, deviceModel: String) { + _uiState.update { + it.copy( + isConnecting = false, + foundDeviceId = deviceId, + deviceModel = deviceModel, + errorMessage = context.getString(R.string.hardware__connect_error), + ) + } + setEffect( + HwConnectEffect.NavigateToFound( + deviceId = deviceId, + deviceModel = deviceModel, + ) + ) + connectJob = null + } + + fun cancelConnect() { + connectJob?.cancel() + connectJob = null + hwWalletRepo.cancelPairingCode() + _uiState.update { it.copy(isConnecting = false) } + } + + fun onLabelChange(value: String) = _uiState.update { it.copy(labelInput = value.take(DEVICE_LABEL_MAX_LENGTH)) } + + fun onFinishClick() { + val deviceId = _uiState.value.pairedDeviceId + if (deviceId == null) { + setEffect(HwConnectEffect.Dismiss) + return + } + viewModelScope.launch { + hwWalletRepo.setDeviceLabel(deviceId, _uiState.value.labelInput) + setEffect(HwConnectEffect.Dismiss) + } + } + + fun resetState() { + searchJob?.cancel() + searchJob = null + connectJob?.cancel() + connectJob = null + hwWalletRepo.cancelPairingCode() + labelInitialized = false + includeBluetoothInScan = true + scanUsbBeforeConnect = false + _uiState.update { HwConnectUiState() } + } + + private fun startSearching() { + if (searchJob?.isActive == true) return + scanUsbBeforeConnect = false + _uiState.update { it.copy(isSearching = true, errorMessage = null) } + searchJob = viewModelScope.launch { + while (isActive) { + val scanResult = hwWalletRepo.scan(includeBluetooth = includeBluetoothInScan) + if (scanResult.isFailure) { + _uiState.update { + it.copy(errorMessage = context.getString(R.string.hardware__search_error)) + } + delay(SCAN_INTERVAL) + continue + } + _uiState.update { it.copy(errorMessage = null) } + val device = hwWalletRepo.deviceState.value.nearbyDevices.firstOrNull() + if (device != null) { + val deviceModel = resolveHwWalletName(label = null, model = device.model) + _uiState.update { + it.copy( + isSearching = false, + foundDeviceId = device.id, + deviceModel = deviceModel, + errorMessage = null, + ) + } + setEffect(HwConnectEffect.NavigateToFound(device.id, deviceModel)) + return@launch + } + delay(SCAN_INTERVAL) + } + } + } + + private fun onConnected(deviceId: String, features: TrezorFeatures) { + val name = resolveHwWalletName(label = features.label, model = features.model) + _uiState.update { + it.copy( + isConnecting = false, + pairedDeviceId = deviceId, + deviceName = name, + labelInput = if (labelInitialized) it.labelInput else name, + errorMessage = null, + ) + } + labelInitialized = true + setEffect(HwConnectEffect.NavigateToPaired) + } + + private fun observePairingCode() { + viewModelScope.launch { + hwWalletRepo.needsPairingCode.collect { needsCode -> + if (needsCode) setEffect(HwConnectEffect.NavigateToPairCode) + } + } + } + + private fun observeConnectedWallet() { + viewModelScope.launch { + hwWalletRepo.wallets.collect { wallets -> + val deviceId = _uiState.value.pairedDeviceId ?: return@collect + val wallet = wallets.firstOrNull { deviceId == it.id || deviceId in it.deviceIds } ?: return@collect + _uiState.update { + it.copy( + deviceName = wallet.name, + balanceSats = wallet.balanceSats, + labelInput = if (labelInitialized) it.labelInput else wallet.name, + ) + } + labelInitialized = true + } + } + } + + private fun setEffect(effect: HwConnectEffect) = viewModelScope.launch { _effects.emit(effect) } +} + +@Immutable +data class HwConnectUiState( + val isSearching: Boolean = false, + val isConnecting: Boolean = false, + val foundDeviceId: String? = null, + val pairedDeviceId: String? = null, + val deviceName: String = "", + val deviceModel: String = "", + val balanceSats: ULong = 0uL, + val labelInput: String = "", + val errorMessage: String? = null, +) + +sealed interface HwConnectEffect { + data object NavigateToSearching : HwConnectEffect + data class NavigateToFound(val deviceId: String, val deviceModel: String) : HwConnectEffect + data object NavigateToPairCode : HwConnectEffect + data object NavigateToPaired : HwConnectEffect + data object Dismiss : 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 new file mode 100644 index 0000000000..440c10ae6d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwFoundSheet.kt @@ -0,0 +1,142 @@ +package to.bitkit.ui.sheets.hardware + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheetPreview +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.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun HwFoundSheet( + deviceModel: String, + modifier: Modifier = Modifier, + isConnecting: Boolean = false, + errorMessage: String? = null, + onConnect: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + Content( + deviceModel = deviceModel, + isConnecting = isConnecting, + errorMessage = errorMessage, + onConnect = onConnect, + onCancel = onCancel, + modifier = modifier + ) +} + +@Composable +private fun Content( + deviceModel: String, + modifier: Modifier = Modifier, + isConnecting: Boolean = false, + errorMessage: String? = null, + onConnect: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("HardwareWalletFoundScreen") + ) { + SheetTopBar(titleText = stringResource(R.string.hardware__found_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__found_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__found_text, deviceModel), color = Colors.White64) + AnimatedVisibility(visible = errorMessage != null) { + Column { + VerticalSpacer(16.dp) + BodyS( + text = errorMessage.orEmpty(), + color = Colors.Red, + modifier = Modifier.testTag("HwFoundError") + ) + } + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(R.drawable.trezor), + contentDescription = null, + modifier = Modifier.size(256.dp) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletFoundCancel") + ) + PrimaryButton( + text = stringResource(R.string.common__connect), + onClick = onConnect, + isLoading = isConnecting, + enabled = !isConnecting, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletFoundConnect") + ) + } + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + deviceModel = "Trezor Safe 3", + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwIntroSheet.kt index 8f2f3fbee0..ac929a4760 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwIntroSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwIntroSheet.kt @@ -22,6 +22,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -30,10 +31,12 @@ import to.bitkit.ui.utils.withAccent @Composable fun HwIntroSheet( modifier: Modifier = Modifier, - onDismiss: () -> Unit = {}, + onContinue: () -> Unit = {}, + onCancel: () -> Unit = {}, ) { Content( - onDismiss = onDismiss, + onContinue = onContinue, + onCancel = onCancel, modifier = modifier ) } @@ -41,14 +44,15 @@ fun HwIntroSheet( @Composable private fun Content( modifier: Modifier = Modifier, - onDismiss: () -> Unit = {}, + onContinue: () -> Unit = {}, + onCancel: () -> Unit = {}, ) { Column( modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .testTag("hw_intro_screen") + .testTag("HardwareWalletIntroScreen") ) { SheetTopBar(titleText = stringResource(R.string.hardware__intro_title)) HwDeviceIllustrations( @@ -71,27 +75,30 @@ private fun Content( ) { SecondaryButton( text = stringResource(R.string.common__cancel), - onClick = onDismiss, - modifier = Modifier.weight(1f) + onClick = onCancel, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletIntroCancel") ) PrimaryButton( text = stringResource(R.string.common__continue), - onClick = {}, - enabled = false, - modifier = Modifier.weight(1f) + onClick = onContinue, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletIntroContinue") ) } - VerticalSpacer(16.dp) } + VerticalSpacer(16.dp) } } @Preview(showSystemUi = true) @Composable -private fun PreviewIntro() { +private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content() + Content(modifier = Modifier.sheetHeight()) } } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt index 7e8f92e95a..1df72ef573 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt @@ -1,5 +1,8 @@ package to.bitkit.ui.sheets.hardware +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -17,6 +21,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -26,15 +32,21 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors private const val PAIRING_CODE_LENGTH = 6 +private const val PAIRING_CHAR_COLLAPSED_SCALE_X = 0.15f +private const val PAIRING_CHAR_COLLAPSED_SCALE_Y = 0.85f private val PAIRING_CELL_WIDTH = 32.dp +private val PAIRING_CELL_SPACING = 8.dp @Composable fun HwPairCodeSheet( @@ -53,6 +65,7 @@ fun HwPairCodeSheet( Content( code = code, + submitting = submitted, onKeyPress = { key -> when { key == KEY_DELETE -> code = code.dropLast(1) @@ -72,15 +85,16 @@ fun HwPairCodeSheet( @Composable private fun Content( code: String, - onKeyPress: (String) -> Unit, modifier: Modifier = Modifier, + submitting: Boolean = false, + onKeyPress: (String) -> Unit = {}, ) { Column( modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .testTag("hw_pair_screen") + .testTag("HardwareWalletPairCodeScreen") ) { SheetTopBar(titleText = stringResource(R.string.hardware__pairing_title)) Column( @@ -92,26 +106,69 @@ private fun Content( ) { BodyM(stringResource(R.string.hardware__pairing_text), color = Colors.White64) FillHeight() - // Fixed-width cells so digits replace dots without the row shifting. - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - repeat(PAIRING_CODE_LENGTH) { index -> - val digit = code.getOrNull(index)?.toString() - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.width(PAIRING_CELL_WIDTH) - ) { - Display( - text = digit ?: "•", - color = if (digit != null) Colors.White else Colors.White32, - ) - } - } - } + PinInput(submitting, code) FillHeight() } NumberPad( onPress = onKeyPress, + enabled = !submitting, ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun PinInput( + submitting: Boolean, + code: String, +) { + val submitProgress by animateFloatAsState( + targetValue = if (submitting) 1f else 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), + label = "pairCodeSubmit", + ) + val cellStepPx = with(LocalDensity.current) { + (PAIRING_CELL_WIDTH + PAIRING_CELL_SPACING).toPx() + } + Box(contentAlignment = Alignment.Center) { + // Fixed-width cells so digits replace dots without the row shifting. + Row( + horizontalArrangement = Arrangement.spacedBy(PAIRING_CELL_SPACING), + ) { + repeat(PAIRING_CODE_LENGTH) { index -> + val digit = code.getOrNull(index)?.toString() + val centerOffset = (PAIRING_CODE_LENGTH - 1) / 2f - index + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(PAIRING_CELL_WIDTH) + .graphicsLayer { + alpha = 1f - submitProgress + translationX = centerOffset * cellStepPx * submitProgress + scaleX = 1f - (1f - PAIRING_CHAR_COLLAPSED_SCALE_X) * submitProgress + scaleY = 1f - (1f - PAIRING_CHAR_COLLAPSED_SCALE_Y) * submitProgress + } + ) { + Display( + text = digit ?: "•", + color = if (digit != null) Colors.White else Colors.White32, + ) + } + } + } + if (submitting) { + GradientCircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(32.dp) + .graphicsLayer { + alpha = submitProgress + val spinnerScale = 0.8f + 0.2f * submitProgress + scaleX = spinnerScale + scaleY = spinnerScale + } + ) + } } } @@ -122,7 +179,21 @@ private fun Preview() { BottomSheetPreview { Content( code = "123", - onKeyPress = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewSubmitting() { + AppThemeSurface { + BottomSheetPreview { + Content( + code = "123", + submitting = 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 new file mode 100644 index 0000000000..1db872c638 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt @@ -0,0 +1,143 @@ +package to.bitkit.ui.sheets.hardware + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.HW_ILLUSTRATION_SIZE_RATIO +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.WalletBalanceView +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Width reuses the shared 256-wide Figma Visual ratio; aspect is the coin_stack_3 asset's intrinsic 756x926. */ +private const val COINS_ASPECT_RATIO = 756f / 926f + +@Composable +fun HwPairedSheet( + uiState: HwConnectUiState, + modifier: Modifier = Modifier, + onLabelChange: (String) -> Unit = {}, + onFinish: () -> Unit = {}, +) { + Content( + uiState = uiState, + onLabelChange = onLabelChange, + onFinish = onFinish, + modifier = modifier + ) +} + +@Composable +private fun Content( + uiState: HwConnectUiState, + modifier: Modifier = Modifier, + onLabelChange: (String) -> Unit = {}, + onFinish: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .imePadding() + .testTag("HardwareWalletPairedScreen") + ) { + SheetTopBar(titleText = stringResource(R.string.hardware__paired_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__paired_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__paired_text), color = Colors.White64) + VerticalSpacer(32.dp) + Row(modifier = Modifier.fillMaxWidth()) { + WalletBalanceView( + title = uiState.deviceName, + sats = uiState.balanceSats.toLong(), + icon = painterResource(R.drawable.ic_btc_circle_blue), + ) + } + VerticalSpacer(32.dp) + Caption13Up(stringResource(R.string.hardware__paired_label), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = uiState.labelInput, + onValueChange = onLabelChange, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag("HardwareWalletLabelInput") + ) + } + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds() + ) { + Image( + painter = painterResource(R.drawable.coin_stack_3), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomCenter) + .width(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .aspectRatio(COINS_ASPECT_RATIO) + ) + } + PrimaryButton( + text = stringResource(R.string.hardware__paired_finish), + onClick = onFinish, + modifier = Modifier + .padding(horizontal = 32.dp) + .testTag("HardwareWalletPairedFinish") + ) + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = HwConnectUiState( + deviceName = "Trezor Safe 3", + balanceSats = 10_562_411uL, + labelInput = "Trezor Safe 3", + ), + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwSearchingSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwSearchingSheet.kt new file mode 100644 index 0000000000..c9a065437d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwSearchingSheet.kt @@ -0,0 +1,193 @@ +package to.bitkit.ui.sheets.hardware + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.InfiniteTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Diameter of the loading visual (outer dashed ring). */ +private val ANIMATION_SIZE = 280.dp + +/** Arrows width as a fraction of the loader — 256 in the 311-wide Figma "Loading Animation" HW ring. */ +private const val ARROWS_SIZE_RATIO = 256f / 311f + +/** Inner dashed-ring width as a fraction of the loader — 207 in the 311-wide Figma HW ring. */ +private const val INNER_RING_SIZE_RATIO = 207f / 311f + +/** Arrows rotation period: the Figma HW variants step 90° per 1s, a counter-clockwise turn every 4s. */ +private const val ARROWS_SPIN_MS = 4000 + +/** Dashed-ring rotation period: the two rings counter-rotate ~180° per 1s, a turn every ~2s. */ +private const val RING_SPIN_MS = 2000 + +@Composable +fun HwSearchingSheet( + modifier: Modifier = Modifier, + errorMessage: String? = null, + onCancel: () -> Unit = {}, +) { + Content( + errorMessage = errorMessage, + onCancel = onCancel, + modifier = modifier + ) +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + errorMessage: String? = null, + onCancel: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("HardwareWalletSearchingScreen") + ) { + SheetTopBar(titleText = stringResource(R.string.hardware__connect_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__connect_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__connect_text), color = Colors.White64) + AnimatedVisibility(visible = errorMessage != null) { + Column { + VerticalSpacer(16.dp) + BodyS( + text = errorMessage.orEmpty(), + color = Colors.Red, + modifier = Modifier.testTag("HwSearchingError") + ) + } + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + SearchingAnimation() + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier.testTag("HardwareWalletSearchingCancel"), + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SearchingAnimation( + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "hw_searching") + val arrowsRotation by transition.animateRotation(ARROWS_SPIN_MS, clockwise = false, label = "arrows") + val outerRingRotation by transition.animateRotation(RING_SPIN_MS, clockwise = false, label = "outer_ring") + val innerRingRotation by transition.animateRotation(RING_SPIN_MS, clockwise = true, label = "inner_ring") + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(ANIMATION_SIZE) + ) { + Image( + painter = painterResource(R.drawable.hw_searching_ring), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .rotate(outerRingRotation) + ) + Image( + painter = painterResource(R.drawable.hw_searching_ring_inner), + contentDescription = null, + modifier = Modifier + .fillMaxSize(INNER_RING_SIZE_RATIO) + .rotate(innerRingRotation) + ) + Image( + painter = painterResource(R.drawable.hw_searching_arrows), + contentDescription = null, + modifier = Modifier + .fillMaxSize(ARROWS_SIZE_RATIO) + .rotate(arrowsRotation) + ) + } +} + +@Composable +private fun InfiniteTransition.animateRotation(durationMillis: Int, clockwise: Boolean, label: String) = + animateFloat( + initialValue = 0f, + targetValue = if (clockwise) 360f else -360f, + animationSpec = infiniteRepeatable(tween(durationMillis = durationMillis, easing = LinearEasing)), + label = label, + ) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content(modifier = Modifier.sheetHeight()) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewError() { + AppThemeSurface { + BottomSheetPreview { + Content( + errorMessage = stringResource(R.string.hardware__search_error), + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d22d78d01a..915f9f8b86 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3058,7 +3058,28 @@ class AppViewModel @Inject constructor( } } - fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored(TransportType.USB) + fun onUsbDeviceAttached( + deviceId: String? = null, + deviceModel: String = "", + ) { + hwWalletRepo.onTransportRestored(TransportType.USB) + deviceId ?: return + + viewModelScope.launch { + if (hwWalletRepo.hasKnownDevice(deviceId)) return@launch + if (isHighPrioritySheet(_currentSheet.value)) return@launch + if (_currentSheet.value is Sheet.Hardware) return@launch + + showSheet( + Sheet.Hardware( + route = HardwareRoute.Found( + deviceId = deviceId, + deviceModel = deviceModel, + ), + ) + ) + } + } fun submitPairingCode(code: String) = hwWalletRepo.submitPairingCode(code) @@ -3075,6 +3096,10 @@ class AppViewModel @Inject constructor( return } + // The Connect Hardware flow is itself a Hardware sheet and drives the pair-code step + // inline within its own NavHost; replacing it here would tear down that back stack. + if (_currentSheet.value is Sheet.Hardware) return + isPairingCodeSheetQueued = false showSheet(Sheet.Hardware(route = HardwareRoute.PairCode)) } diff --git a/app/src/main/res/drawable-nodpi/coin_stack_3.png b/app/src/main/res/drawable-nodpi/coin_stack_3.png deleted file mode 100644 index d07f08b33d..0000000000 Binary files a/app/src/main/res/drawable-nodpi/coin_stack_3.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/coin_stack_3.webp b/app/src/main/res/drawable-nodpi/coin_stack_3.webp new file mode 100644 index 0000000000..97ac05d212 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/coin_stack_3.webp differ diff --git a/app/src/main/res/drawable-nodpi/hw_searching_arrows.png b/app/src/main/res/drawable-nodpi/hw_searching_arrows.png new file mode 100644 index 0000000000..eced4a766d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/hw_searching_arrows.png differ diff --git a/app/src/main/res/drawable-nodpi/hw_searching_ring.png b/app/src/main/res/drawable-nodpi/hw_searching_ring.png new file mode 100644 index 0000000000..8e30fa63d4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/hw_searching_ring.png differ diff --git a/app/src/main/res/drawable-nodpi/hw_searching_ring_inner.png b/app/src/main/res/drawable-nodpi/hw_searching_ring_inner.png new file mode 100644 index 0000000000..8d94f0249d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/hw_searching_ring_inner.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96a239a800..9df11ac3a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,19 +164,36 @@ ±1-2 hours ±1h Slow + Open Settings + Bitkit needs the nearby-devices (Bluetooth) permission to find your hardware wallet. Enable it in Settings, then try again. + Bluetooth access needed + Could not connect to your Trezor. Check that it is unlocked and try again. + Searching for <accent>devices</accent> + Please connect your hardware wallet now via USB or Bluetooth. + Connect Device Connected via Bluetooth Connected via USB Disconnected via Bluetooth Disconnected via USB + Trezor + Found <accent>Trezor</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 + Finish + Paired <accent>Trezor</accent> + Label Funds + Bitkit found funds on your device and added these to your balance. + Device Connected Enter the 6-digit code shown on your hardware device. Pair Device Remove %1$s Don\'t worry, your funds are safe and your coins won\'t be deleted. Bitkit will simply stop displaying the amounts in the wallet. Remove %1$s Could not remove the hardware wallet. Please try again. + Could not search for hardware wallets. Check your connection and try again. Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. Funds\n<accent>availability</accent> Balance diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index dd76a0c86e..5ee3bf89f5 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -34,12 +35,14 @@ import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class HwWalletRepoTest : BaseUnitTest() { private val trezorRepo = mock() @@ -752,6 +755,84 @@ class HwWalletRepoTest : BaseUnitTest() { confirmations = 3u, ) + @Test + fun `scan delegates to trezorRepo`() = test { + whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) + val sut = createRepo() + + sut.scan(includeBluetooth = false) + + verify(trezorRepo).scan(includeBluetooth = false) + } + + @Test + fun `connect delegates to trezorRepo`() = test { + val features = mock() + whenever(trezorRepo.connect("dev1")).thenReturn(Result.success(features)) + val sut = createRepo() + + sut.connect("dev1") + + verify(trezorRepo).resetWalletSelection() + verify(trezorRepo).connect("dev1") + } + + @Test + fun `setDeviceLabel persists the trimmed custom label on the matching device`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val sut = createRepo() + + val result = sut.setDeviceLabel("dev1", " My Cold Wallet ") + + assertTrue(result.isSuccess) + verify(hwWalletStore).saveKnownDevices(listOf(device.copy(customLabel = "My Cold Wallet"))) + } + + @Test + fun `setDeviceLabel caps the persisted custom label`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val sut = createRepo() + + val result = sut.setDeviceLabel("dev1", "a".repeat(51)) + + assertTrue(result.isSuccess) + verify(hwWalletStore).saveKnownDevices(listOf(device.copy(customLabel = "a".repeat(50)))) + } + + @Test + fun `setDeviceLabel clears the custom label when blank`() = test { + val labelled = device.copy(customLabel = "Old") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(labelled)) + val sut = createRepo() + + sut.setDeviceLabel("dev1", " ") + + verify(hwWalletStore).saveKnownDevices(listOf(labelled.copy(customLabel = null))) + } + + @Test + fun `setDeviceLabel applies to every entry sharing the wallet identity`() = test { + val sharedXpubs = mapOf("nativeSegwit" to "zpubShared") + val ble = device.copy(id = "ble1", xpubs = sharedXpubs) + val usb = device.copy(id = "usb1", transportType = TransportType.USB, xpubs = sharedXpubs) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(ble, usb)) + val sut = createRepo() + + sut.setDeviceLabel("usb1", "Shared") + + verify(hwWalletStore).saveKnownDevices( + listOf(ble.copy(customLabel = "Shared"), usb.copy(customLabel = "Shared")), + ) + } + + @Test + fun `wallet name prefers the custom label over the device label`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(customLabel = "My Cold Wallet"))) + val sut = createRepo() + + assertEquals("My Cold Wallet", sut.wallets.value.single().name) + } + private suspend fun wheneverStartWatcher() = whenever( trezorRepo.startWatcher( any(), diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 6898760294..bf31c7092b 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -146,6 +146,7 @@ class TrezorRepoTest : BaseUnitTest() { model: String? = DEVICE_MODEL, transportType: TransportType = TransportType.USB, xpubs: Map = emptyMap(), + customLabel: String? = null, ) = KnownDevice( id = id, name = name, @@ -155,6 +156,7 @@ class TrezorRepoTest : BaseUnitTest() { model = model, lastConnectedAt = 123L, xpubs = xpubs, + customLabel = customLabel, ) // region initialize @@ -170,6 +172,18 @@ class TrezorRepoTest : BaseUnitTest() { assertNull(sut.state.value.error) } + @Test + fun `initialize should reuse completed setup`() = test { + sut = createSut() + + val firstResult = sut.initialize() + val secondResult = sut.initialize() + + assertTrue(firstResult.isSuccess) + assertTrue(secondResult.isSuccess) + verify(trezorService, times(1)).initialize(anyOrNull()) + } + @Test fun `initialize should set error on failure`() = test { whenever(trezorService.initialize(anyOrNull())).thenThrow(RuntimeException("init failed")) @@ -200,6 +214,33 @@ class TrezorRepoTest : BaseUnitTest() { assertFalse(sut.state.value.isScanning) } + @Test + fun `scan should initialize Trezor before scanning`() = test { + val devices = listOf(mockDeviceInfo()) + whenever(trezorService.scan()).thenReturn(devices) + sut = createSut() + + val result = sut.scan() + + assertTrue(result.isSuccess) + assertTrue(sut.state.value.isInitialized) + verify(trezorService).initialize(anyOrNull()) + verify(trezorService).scan() + } + + @Test + fun `scan should pass bluetooth flag to service`() = test { + val devices = listOf(mockDeviceInfo()) + whenever(trezorService.scan(includeBluetooth = false)).thenReturn(devices) + sut = createSut() + + val result = sut.scan(includeBluetooth = false) + + assertTrue(result.isSuccess) + assertEquals(devices, result.getOrNull()) + verify(trezorService).scan(includeBluetooth = false) + } + @Test fun `scan should exclude known devices from nearbyDevices state`() = test { val knownDevice = mockKnownDevice() @@ -545,6 +586,26 @@ class TrezorRepoTest : BaseUnitTest() { ) } + @Test + fun `connect preserves stored custom label over stale state label`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()) + .thenReturn(listOf(mockKnownDevice())) + .thenReturn(listOf(mockKnownDevice(customLabel = "Cold Storage"))) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals("Cold Storage", captor.lastValue.single().customLabel) + } + @Test fun `connect should retry once for retryable THP errors`() = test { val features = mockFeatures() @@ -674,6 +735,20 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).reset() } + @Test + fun `resetState clears initialized setup gate`() = test { + val devices = listOf(mockDeviceInfo()) + whenever(trezorService.scan()).thenReturn(devices) + sut = createSut() + + sut.initialize() + sut.resetState() + val result = sut.scan() + + assertTrue(result.isSuccess) + verify(trezorService, times(2)).initialize(anyOrNull()) + } + // endregion // region getAddress @@ -803,6 +878,15 @@ class TrezorRepoTest : BaseUnitTest() { assertFalse(sut.hasKnownDevices()) } + @Test + fun `hasKnownDevice should match stored device path`() = test { + val knownDevice = mockKnownDevice(path = "/dev/bus/usb/001/002") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + assertTrue(sut.hasKnownDevice("/dev/bus/usb/001/002")) + } + // endregion // region autoReconnect diff --git a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt index 896314aa74..4b79152802 100644 --- a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue class TrezorTransportTest { @@ -44,6 +45,25 @@ class TrezorTransportTest { verify(usbManager, never()).requestPermission(eq(device), any()) } + @Test + fun `enumerateDevices can skip bluetooth scan`() { + 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()) + whenever(bridgeTransport.enumerateDevices()).thenReturn(emptyList()) + val sut = createSut() + + val result = runBlocking { + sut.withBluetoothScanningEnabled(false) { + sut.enumerateDevices() + } + } + + assertTrue(result.isEmpty()) + verify(context, never()).getSystemService(Context.BLUETOOTH_SERVICE) + } + private fun createSut() = TrezorTransport( context = context, bridgeTransport = bridgeTransport, 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 new file mode 100644 index 0000000000..f5a22b69c2 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -0,0 +1,283 @@ +package to.bitkit.ui.sheets.hardware + +import android.content.Context +import app.cash.turbine.test +import com.synonym.bitkitcore.TrezorDeviceInfo +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 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.HwWallet +import to.bitkit.models.TransportType +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.TrezorState +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HwConnectViewModelTest : BaseUnitTest() { + + private val hwWalletRepo = mock() + private val context = mock() + private val needsPairingCode = MutableStateFlow(false) + private val wallets = MutableStateFlow>(persistentListOf()) + private val deviceState = MutableStateFlow(TrezorState()) + + private lateinit var sut: HwConnectViewModel + + @Before + fun setUp() { + whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletRepo.wallets).thenReturn(wallets) + whenever(hwWalletRepo.deviceState).thenReturn(deviceState) + whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_ERROR) + whenever(context.getString(R.string.hardware__search_error)).thenReturn(SEARCH_ERROR) + sut = HwConnectViewModel( + hwWalletRepo = hwWalletRepo, + context = context, + ) + } + + @Test + fun `onIntroContinue searches then advances to found with the first discovered device`() = test { + deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3"))) + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList())) + + sut.effects.test { + sut.onIntroContinue() + assertEquals(HwConnectEffect.NavigateToSearching, awaitItem()) + assertEquals(HwConnectEffect.NavigateToFound("dev1", "Trezor Safe 3"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).scan(includeBluetooth = true) + assertEquals("dev1", sut.uiState.value.foundDeviceId) + assertEquals("Trezor Safe 3", sut.uiState.value.deviceModel) + } + + @Test + fun `onIntroContinue can search without bluetooth`() = test { + deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("usb1", model = "Safe 5"))) + whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) + + sut.effects.test { + sut.onIntroContinue(includeBluetooth = false) + assertEquals(HwConnectEffect.NavigateToSearching, awaitItem()) + assertEquals(HwConnectEffect.NavigateToFound("usb1", "Trezor Safe 5"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).scan(includeBluetooth = false) + assertEquals("usb1", sut.uiState.value.foundDeviceId) + } + + @Test + fun `onIntroContinue surfaces search failures while searching`() = test { + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.failure(AppError("scan failed"))) + + sut.effects.test { + sut.onIntroContinue() + assertEquals(HwConnectEffect.NavigateToSearching, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + runCurrent() + verify(hwWalletRepo).scan(includeBluetooth = true) + assertTrue(sut.uiState.value.isSearching) + assertEquals(SEARCH_ERROR, sut.uiState.value.errorMessage) + sut.resetState() + } + + @Test + fun `onConnectClick scans usb before connecting a device seeded by route`() = test { + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) + whenever(hwWalletRepo.connect("usb1")).thenReturn(Result.success(connectedFeatures)) + sut.onFoundRoute(deviceId = "usb1", deviceModel = "Trezor") + + sut.effects.test { + sut.onConnectClick() + assertEquals(HwConnectEffect.NavigateToPaired, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).scan(includeBluetooth = false) + verify(hwWalletRepo).connect("usb1") + } + + @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 usbDevice = deviceInfo( + id = "core-usb-id", + model = "Safe 5", + transportType = TrezorTransportType.USB, + path = path, + ) + whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(listOf(usbDevice))) + whenever(hwWalletRepo.connect("core-usb-id")).thenReturn(Result.success(connectedFeatures)) + sut.onFoundRoute(deviceId = path, deviceModel = "Trezor") + + sut.effects.test { + sut.onConnectClick() + assertEquals(HwConnectEffect.NavigateToPaired, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).connect("core-usb-id") + assertEquals("core-usb-id", sut.uiState.value.foundDeviceId) + } + + @Test + fun `onConnectClick returns to found when connect fails from pair code`() = test { + whenever(hwWalletRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) + whenever(hwWalletRepo.connect("usb1")).thenReturn(Result.failure(AppError("connect failed"))) + sut.onFoundRoute(deviceId = "usb1", deviceModel = "Trezor Safe 5") + + sut.effects.test { + sut.onConnectClick() + assertEquals(HwConnectEffect.NavigateToFound("usb1", "Trezor Safe 5"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertFalse(sut.uiState.value.isConnecting) + assertEquals(CONNECT_ERROR, sut.uiState.value.errorMessage) + } + + @Test + fun `onConnectClick connects the found device and advances to paired`() = test { + givenDeviceFound() + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + + sut.effects.test { + sut.onConnectClick() + assertEquals(HwConnectEffect.NavigateToPaired, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).connect("dev1") + assertEquals("dev1", sut.uiState.value.pairedDeviceId) + assertEquals("Trezor Safe 3", sut.uiState.value.labelInput) + } + + @Test + fun `onConnectClick surfaces connect failures and allows retry`() = test { + givenDeviceFound() + runCurrent() + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.failure(AppError("connect failed"))) + + sut.onConnectClick() + runCurrent() + + verify(hwWalletRepo).connect("dev1") + assertFalse(sut.uiState.value.isConnecting) + assertEquals(CONNECT_ERROR, sut.uiState.value.errorMessage) + assertEquals("dev1", sut.uiState.value.foundDeviceId) + } + + @Test + fun `pairing code request surfaces the inline pair code step`() = test { + sut.effects.test { + needsPairingCode.value = true + assertEquals(HwConnectEffect.NavigateToPairCode, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `connected wallet updates the balance shown on the paired step`() = test { + givenDeviceFound() + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + sut.onConnectClick() + + wallets.value = persistentListOf(hwWallet("dev1", name = "Trezor Safe 3", balance = 10_562_411uL)) + + assertEquals(10_562_411uL, sut.uiState.value.balanceSats) + assertEquals("Trezor Safe 3", sut.uiState.value.deviceName) + } + + @Test + fun `onLabelChange caps the label input`() { + sut.onLabelChange("a".repeat(51)) + + assertEquals("a".repeat(50), sut.uiState.value.labelInput) + } + + @Test + fun `onFinishClick persists the edited label and dismisses`() = test { + givenDeviceFound() + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + sut.onConnectClick() + sut.onLabelChange("My Cold Wallet") + whenever(hwWalletRepo.setDeviceLabel("dev1", "My Cold Wallet")).thenReturn(Result.success(Unit)) + + sut.effects.test { + sut.onFinishClick() + assertEquals(HwConnectEffect.Dismiss, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).setDeviceLabel("dev1", "My Cold Wallet") + } + + private suspend fun givenDeviceFound() { + deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3"))) + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList())) + sut.onIntroContinue() + } + + private fun deviceInfo( + id: String, + model: String?, + transportType: TrezorTransportType = TrezorTransportType.BLUETOOTH, + path: String = "ble:$id", + ) = TrezorDeviceInfo( + id = id, + 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 hwWallet(id: String, name: String, balance: ULong) = HwWallet( + id = id, + name = name, + model = null, + transportType = TransportType.BLUETOOTH, + isConnected = true, + balanceSats = balance, + activities = persistentListOf(), + deviceIds = persistentSetOf(id), + ) + + private companion object { + const val CONNECT_ERROR = "Could not connect" + const val SEARCH_ERROR = "Could not search" + } +} diff --git a/changelog.d/next/1033.added.md b/changelog.d/next/1033.added.md new file mode 100644 index 0000000000..592661eacb --- /dev/null +++ b/changelog.d/next/1033.added.md @@ -0,0 +1 @@ +Connect a Trezor hardware wallet from the home suggestion card or Hardware Wallets settings to watch its balance. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 5ea4f3f69f..3bf9ea26da 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -13,14 +13,20 @@ The Bridge transport is HTTP (`TrezorBridgeTransport` → `http://127.0.0.1:2132 - **Reliably simulated**: the device itself (deterministic seed and label), the full wallet protocol (scan, connect, features, xpubs, watchers, signing), and therefore all - home-screen UI behavior: tiles, balances, activity, indicators, sheets. + home-screen UI behavior: tiles, balances, activity, indicators, sheets. This includes the + Connect Hardware flow's Intro → Searching → Found → Paired steps (the Bridge device pairs + without the inline pair-code step). - **Partially simulated**: the USB attach → auto-reconnect chain. The OS-level attach intent can be injected with `am start -a android.hardware.usb.action.USB_DEVICE_ATTACHED`, - which drives the full in-app path (MainActivity → AppViewModel → reconnect loop), with - the Bridge standing in for the transport. + which drives the in-app reconnect path (MainActivity → AppViewModel → reconnect loop), with + the Bridge standing in for the transport. A real OS chooser event with a `UsbDevice` extra + is still needed to verify the "Open with Bitkit" path that opens the Found Device sheet for + an unpaired Trezor. - **Not simulated**: kernel/libusbhost behavior, USB enumeration timing, permission - grants, the OS app picker, and THP pairing (the Pair Device sheet). Those need a - physical device. + grants, the OS app picker, BLE runtime/settings recovery, THP one-time pairing code + (the inline Pair Device step), and passphrase/hidden-wallet selection. Those need a physical + device or a dedicated emulator scenario; passphrase coverage is tracked in + synonymdev/bitkit-android#1030. Journey steps that start with `adb:` are device commands the runner executes verbatim instead of UI interactions. @@ -47,18 +53,42 @@ instead of UI interactions. ## Journeys Run in this order — `connect-home-tile.xml` pairs the emulator that the later journeys -rely on, `suggestion-intro-sheet.xml` and `settings-hardware-wallets.xml` each end by -re-pairing after a forget, and `detail-overview.xml` runs last because its final Remove step -forgets the device. +rely on, `suggestion-intro-sheet.xml`, `connect-flow.xml` and `settings-hardware-wallets.xml` +each end by re-pairing after a forget, and `detail-overview.xml` runs last because its final +Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, detail fallback | -| `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect | -| `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, connect intro sheet | -| `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, Add button sheet, per-row delete confirm + re-pair | -| `detail-overview.xml` | Detail screen overview, Transfer placeholder, activity, Remove confirm + forget | +| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect; physical-device chooser path noted separately | +| `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, full connect flow (Intro → Searching → Found → Paired → Finish) re-pairs | +| `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | +| `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, Add button sheet/back dismiss, per-row delete confirm + re-pair | +| `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | + +Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, +`HardwareWalletIntroCancel`, `HardwareWalletIntroContinue`, +`HardwareWalletSearchingScreen`, `HardwareWalletSearchingCancel`, +`HardwareWalletFoundScreen`, `HardwareWalletFoundCancel`, +`HardwareWalletFoundConnect`, `HardwareWalletPairedScreen`, +`HardwareWalletLabelInput`, `HardwareWalletPairedFinish`, +`HardwareWalletPairCodeScreen` (inline pair code, physical device only), +`HwSearchingError`, and `HwFoundError`. + +The current Connect Hardware sheet starts USB discovery immediately after Continue. BLE is +included only once Android nearby-devices permission is granted and Bluetooth is enabled. +The sheet has no internal back navigation; Android back dismisses the sheet. + +If Android shows the Nearby devices/Bluetooth runtime permission prompt after tapping +Continue, allow it and keep waiting on the Searching step. If permission is denied, Bitkit +should show its Bluetooth access recovery dialog with an Open Settings action; that recovery +path is better validated on a physical device because the Bridge path can still find devices +without BLE. + +Current journeys pair the standard wallet. Hidden/passphrase wallet behavior is intentionally +not asserted here yet; it needs explicit UX and identity-scoping coverage as described in +synonymdev/bitkit-android#1030. To exercise the received-money sheet (not covered by a journey because it needs an out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, e.g. diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index f90647a94d..1b91a4d0c0 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,9 +1,10 @@ Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the activity detail - fallback. Requires a paired Bridge emulator whose wallet has at least one on-chain - transaction (run connect-home-tile.xml first; fund per README.md if the + Activity screen with blue icon variants, filter behavior, and the current watch-only + activity detail fallback until Core-backed hardware activity support lands. Requires a + paired Bridge emulator whose wallet has at least one on-chain transaction (run + connect-home-tile.xml first; fund per README.md if the deterministic wallet has no history). diff --git a/journeys/hardware-wallet/connect-flow.xml b/journeys/hardware-wallet/connect-flow.xml new file mode 100644 index 0000000000..bc03a52a3c --- /dev/null +++ b/journeys/hardware-wallet/connect-flow.xml @@ -0,0 +1,51 @@ + + + Verifies the Connect Hardware flow entered from Hardware Wallets settings: forgets the + paired device so it can be rediscovered, opens Settings then Hardware Wallets, taps Add, + and runs Searching, Found and Paired, editing the Label Funds field before finishing. + Confirms the paired device is listed under its custom label, which re-pairs the emulator + so later journeys can run. Requires a paired Bridge emulator (run connect-home-tile.xml + first). + + + + Launch the Bitkit app, open the menu, navigate to Settings, then Dev Settings, then tap the "Trezor" row + + + Tap the forget-device (trash) icon next to the known device, confirming any dialog, so the device becomes discoverable again + + + Navigate back to Settings, open General, then Payments, then tap the "Hardware Wallets" row + + + Verify the Hardware Wallets screen (testTag "HardwareWalletsScreen") shows no paired devices + + + Tap the "Add Hardware Wallet" button (testTag "AddHardwareWallet") + + + Verify a bottom sheet opens titled "Hardware Wallet" (testTag "HardwareWalletIntroScreen"); tap the "Continue" button (testTag "HardwareWalletIntroContinue") + + + Verify the sheet advances to the "Connect Device" step (testTag "HardwareWalletSearchingScreen") headed "Searching for devices" + + + If Android shows a Nearby devices/Bluetooth permission prompt or Bluetooth enable prompt, allow it and continue waiting on the Searching step + + + Verify the sheet advances to the "Found Device" step (testTag "HardwareWalletFoundScreen") headed "Found Trezor" within 15 seconds, then tap "Connect" (testTag "HardwareWalletFoundConnect") + + + Verify the sheet advances to the "Device Connected" step (testTag "HardwareWalletPairedScreen") headed "Paired Trezor", showing a balance and an editable "Label Funds" field, with no PIN or pairing prompt + + + Clear the "Label Funds" field (testTag "HardwareWalletLabelInput") and type "My Trezor" + + + Tap the "Finish" button (testTag "HardwareWalletPairedFinish") and verify the sheet closes back to the Hardware Wallets screen + + + Verify the Hardware Wallets screen now lists one paired device named "My Trezor" with a green connection indicator + + + diff --git a/journeys/hardware-wallet/connect-home-tile.xml b/journeys/hardware-wallet/connect-home-tile.xml index e1036603d9..b0aff9a79c 100644 --- a/journeys/hardware-wallet/connect-home-tile.xml +++ b/journeys/hardware-wallet/connect-home-tile.xml @@ -2,8 +2,8 @@ Pairs the Bridge Trezor emulator through the dev Trezor screen, then verifies the home-screen hardware wallet tile: name, connection indicator, balance, and the - not-yet-implemented overview notice. Requires the bitkit-docker Trezor User Env - running and a Bridge-enabled build (see README.md). + hardware wallet detail screen. Requires the bitkit-docker Trezor User Env running + and a Bridge-enabled build (see README.md). @@ -40,7 +40,7 @@ Tap the hardware wallet tile - Verify the hardware wallet detail screen opens, showing the device name with a blue bitcoin icon in the top bar and a balance header + Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header Navigate back to the wallet home screen diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index e9489d6704..e8175b31cf 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -1,10 +1,10 @@ Opens the hardware wallet detail screen from the home tile and verifies its overview: - the top bar, balance header, the not-yet-implemented Transfer To Spending action, the - grouped activity list, and the Remove device confirm dialog. The final Remove step - forgets the device, so run this last (re-run connect-home-tile.xml to pair again). - Requires a paired Bridge emulator (run connect-home-tile.xml first). + the top bar, balance header, the current Transfer To Spending placeholder when funds + are present, the grouped activity list, and the Remove device confirm dialog. The final + Remove step forgets the device, so run this last (re-run connect-home-tile.xml to pair + again). Requires a paired Bridge emulator (run connect-home-tile.xml first). @@ -14,16 +14,16 @@ Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles - Verify the hardware wallet detail screen opens, showing the device name with a blue bitcoin icon in the top bar and a balance header + Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown, tap it and verify a notice appears saying "Transfer to spending not yet implemented.", otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HwTransferToSpending"), tap it and verify a notice appears saying "Transfer to spending not yet implemented.", otherwise skip this step If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back - Tap the "Remove" button labelled with the device name near the bottom of the screen + Tap the "Remove" button labelled with the device name near the bottom of the screen (testTag "RemoveHardwareWallet") Verify a confirm dialog appears explaining that your funds are safe and your coins won't be deleted diff --git a/journeys/hardware-wallet/settings-hardware-wallets.xml b/journeys/hardware-wallet/settings-hardware-wallets.xml index 1b15a95dae..35e166cb38 100644 --- a/journeys/hardware-wallet/settings-hardware-wallets.xml +++ b/journeys/hardware-wallet/settings-hardware-wallets.xml @@ -21,16 +21,16 @@ Tap the "Hardware Wallets" row - Verify the Hardware Wallets screen opens with the top bar titled "Hardware Wallets" + Verify the Hardware Wallets screen opens (testTag "HardwareWalletsScreen") with the top bar titled "Hardware Wallets" Verify the paired device is listed with its name, a bitcoin balance prefixed with the ₿ symbol, and a green connection indicator on the left - Tap the "Add Hardware Wallet" button near the bottom + Tap the "Add Hardware Wallet" button near the bottom (testTag "AddHardwareWallet") - Verify a bottom sheet opens titled "Hardware Wallet" showing Trezor and Ledger device images, then tap "Cancel" to close it + Verify a bottom sheet opens titled "Hardware Wallet" (testTag "HardwareWalletIntroScreen") showing hardware device illustrations, then press Android back and verify the sheet closes back to the Hardware Wallets screen Tap the trash (delete) icon on the device row diff --git a/journeys/hardware-wallet/suggestion-intro-sheet.xml b/journeys/hardware-wallet/suggestion-intro-sheet.xml index d2a32e4ce7..822c580f24 100644 --- a/journeys/hardware-wallet/suggestion-intro-sheet.xml +++ b/journeys/hardware-wallet/suggestion-intro-sheet.xml @@ -1,9 +1,10 @@ - Verifies the no-device home state: forgetting the paired device removes the - hardware tile, the Hardware suggestion card appears, and tapping it opens the - connect intro sheet with disabled Continue. Re-pairs the emulator at the end so - other journeys can run afterwards. Requires a paired Bridge emulator. + Verifies the no-device home state and the full Connect Hardware flow: forgetting the + paired device removes the hardware tile, the Hardware suggestion card appears, and + tapping it opens the connect intro sheet. Continuing runs Searching, Found and Paired, + which re-pairs the emulator at the end so other journeys can run afterwards. Requires a + paired Bridge emulator. @@ -25,22 +26,31 @@ Tap the "Hardware" suggestion card - Verify a bottom sheet opens titled "Hardware Wallet" showing Trezor and Ledger device images + Verify a bottom sheet opens titled "Hardware Wallet" (testTag "HardwareWalletIntroScreen") showing hardware device illustrations Verify the headline reads "Add your hardware wallet" with the words "hardware wallet" in blue - Verify the "Continue" button appears disabled and tapping it does nothing + Tap the "Continue" button (testTag "HardwareWalletIntroContinue") and verify the sheet advances to the "Connect Device" step (testTag "HardwareWalletSearchingScreen") headed "Searching for devices", showing a loading animation and a "Cancel" button (testTag "HardwareWalletSearchingCancel") - Tap the "Cancel" button and verify the sheet closes back to the home screen + If Android shows a Nearby devices/Bluetooth permission prompt or Bluetooth enable prompt, allow it and continue waiting on the Searching step - Open the menu, navigate to Settings, then Dev Settings, then tap the "Trezor" row, tap "Scan", and tap the discovered device to re-pair it + Verify the sheet advances to the "Found Device" step (testTag "HardwareWalletFoundScreen") within 15 seconds, headed "Found Trezor" with a "Connect" button - Verify the device connects within 15 seconds + Tap the "Connect" button (testTag "HardwareWalletFoundConnect") + + + Verify the sheet advances to the "Device Connected" step (testTag "HardwareWalletPairedScreen") headed "Paired Trezor" within 15 seconds, showing a balance and an editable "Label Funds" field (testTag "HardwareWalletLabelInput") defaulting to the device name, with no PIN or pairing prompt + + + Tap the "Finish" button (testTag "HardwareWalletPairedFinish") and verify the sheet closes back to the home screen + + + Verify the hardware wallet tile reappears beneath the SAVINGS and SPENDING tiles with the device name and a green connection indicator diff --git a/journeys/hardware-wallet/usb-reconnect.xml b/journeys/hardware-wallet/usb-reconnect.xml index 4d4941367c..cc31be8d68 100644 --- a/journeys/hardware-wallet/usb-reconnect.xml +++ b/journeys/hardware-wallet/usb-reconnect.xml @@ -3,8 +3,10 @@ Verifies the disconnect indicator and the USB-attach auto-reconnect chain by injecting the OS attach intent, with the Bridge emulator standing in for the transport. Covers MainActivity intent handling, the serialized reconnect retry - loop, and the prompt-free reconnect. Requires a paired Bridge emulator (run - connect-home-tile.xml first). + loop, and the prompt-free reconnect for an already paired wallet. The unpaired + physical-device OS chooser path ("Open with Bitkit" → Found Device sheet) cannot + be fully simulated by this injected intent because it lacks a real UsbDevice extra. + Requires a paired Bridge emulator (run connect-home-tile.xml first).