From 72b10cbd2e6ce85eac16004b4b7a4a69d0d1a5cd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 12:51:07 +0200 Subject: [PATCH 01/37] feat: home screen hardware wallet ui --- .../main/java/to/bitkit/models/AddressType.kt | 9 + .../java/to/bitkit/models/BalanceState.kt | 3 + .../main/java/to/bitkit/models/Suggestion.kt | 6 + .../to/bitkit/repositories/HwWalletRepo.kt | 174 ++++++++++++++++++ .../java/to/bitkit/repositories/TrezorRepo.kt | 29 +++ .../java/to/bitkit/repositories/WalletRepo.kt | 9 +- .../to/bitkit/services/TrezorTransport.kt | 45 +++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 5 + .../java/to/bitkit/ui/components/SheetHost.kt | 2 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 136 ++++++++++++-- .../bitkit/ui/screens/wallets/HomeUiState.kt | 2 + .../ui/screens/wallets/HomeViewModel.kt | 20 +- .../ui/sheets/HardwareWalletConnectSheet.kt | 68 +++++++ .../viewmodels/ActivityListViewModel.kt | 20 +- .../res/drawable-nodpi/trezor_device.webp | Bin 0 -> 1022 bytes .../res/drawable/ic_bluetooth_connected.xml | 36 ++++ .../main/res/drawable/ic_btc_circle_blue.xml | 14 ++ .../main/res/drawable/ic_usb_connected.xml | 41 +++++ app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/models/BalanceStateTest.kt | 29 +++ .../bitkit/repositories/HwWalletRepoTest.kt | 130 +++++++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 3 + changelog.d/next/998.added.md | 1 + 23 files changed, 760 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt create mode 100644 app/src/main/res/drawable-nodpi/trezor_device.webp create mode 100644 app/src/main/res/drawable/ic_bluetooth_connected.xml create mode 100644 app/src/main/res/drawable/ic_btc_circle_blue.xml create mode 100644 app/src/main/res/drawable/ic_usb_connected.xml create mode 100644 app/src/test/java/to/bitkit/models/BalanceStateTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt create mode 100644 changelog.d/next/998.added.md diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index 7aa39ac7c4..b4a8d5e24a 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -2,6 +2,7 @@ package to.bitkit.models +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AddressType import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env @@ -99,6 +100,14 @@ fun AddressType.toAccountDerivationPath(network: Network = Env.network): String } } +fun AddressType.toAccountType(): AccountType = when (this) { + AddressType.P2TR -> AccountType.TAPROOT + AddressType.P2WPKH -> AccountType.NATIVE_SEGWIT + AddressType.P2SH -> AccountType.WRAPPED_SEGWIT + AddressType.P2PKH -> AccountType.LEGACY + else -> AccountType.NATIVE_SEGWIT +} + fun AddressType.toSettingsString(): String = when (this) { AddressType.P2TR -> "taproot" AddressType.P2WPKH -> "nativeSegwit" diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 5907238e87..0a09e0b271 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -13,6 +13,9 @@ data class BalanceState( val maxSendOnchainSats: ULong = 0uL, val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, + val totalHardwareSats: ULong = 0uL, ) { val totalSats get() = totalOnchainSats + totalLightningSats + + val totalWithHardwareSats get() = totalSats + totalHardwareSats } diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index 8ae3c576de..6b6f0f12cd 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -19,6 +19,12 @@ enum class Suggestion( color = Colors.Brand24, icon = R.drawable.b_emboss, ), + HARDWARE( + title = R.string.cards__hardware__title, + description = R.string.cards__hardware__description, + color = Colors.Blue24, + icon = R.drawable.trezor_device, + ), LIGHTNING( title = R.string.cards__lightning__title, description = R.string.cards__lightning__description, diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt new file mode 100644 index 0000000000..5ba1f4e085 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -0,0 +1,174 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TxDirection +import com.synonym.bitkitcore.WatcherEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.data.TrezorStore +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.toAccountType +import to.bitkit.models.toAddressType +import to.bitkit.models.toCoreNetwork +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Production hardware-wallet business layer. Tracks paired Trezor devices as + * watch-only balances by running one on-chain xpub watcher per (device, address type) + * and exposing the aggregated per-device balance and activity to the UI. + * + * Built on top of [TrezorRepo], which owns the device list, connect orchestration + * and the underlying watcher transport. + */ +@Singleton +class HwWalletRepo @Inject constructor( + private val trezorRepo: TrezorRepo, + private val trezorStore: TrezorStore, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + companion object { + private const val WATCHER_ID_SEPARATOR = "|" + } + + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + + private val activeWatchers = mutableSetOf() + private val _watcherData = MutableStateFlow>(emptyMap()) + + val hardwareWallets: StateFlow> = combine( + trezorStore.data, + trezorRepo.state, + _watcherData, + ) { data, trezorState, watcherData -> + data.knownDevices.map { device -> + val deviceWatchers = watcherData.values.filter { it.deviceId == device.id } + HwWallet( + id = device.id, + name = device.label ?: device.model ?: "Trezor", + model = device.model, + transportType = device.transportType, + isConnected = trezorState.connectedDeviceId == device.id, + balanceSats = deviceWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, + activities = deviceWatchers.flatMap { it.activities }.toImmutableList(), + ) + }.toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + val totalHardwareSats: StateFlow = hardwareWallets + .map { wallets -> wallets.fold(0uL) { acc, wallet -> acc + wallet.balanceSats } } + .stateIn(scope, SharingStarted.Eagerly, 0uL) + + val hardwareActivities: StateFlow> = hardwareWallets + .map { wallets -> wallets.flatMap { it.activities }.toImmutableList() } + .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + init { + observeWatcherEvents() + syncWatchers() + } + + private fun observeWatcherEvents() { + scope.launch { + trezorRepo.watcherEvents.collect { (watcherId, event) -> + if (event !is WatcherEvent.TransactionsChanged) return@collect + val activities = event.transactions.map { it.toOnchainActivity() }.toImmutableList() + _watcherData.update { + it + (watcherId to HwWatcherData(watcherId.toDeviceId(), event.balance.total, activities)) + } + } + } + } + + private fun syncWatchers() { + scope.launch { + trezorStore.data.collect { data -> + val wanted = data.knownDevices.flatMap { device -> + device.xpubs.map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } + } + val wantedIds = wanted.map { it.watcherId }.toSet() + + wanted.forEach { spec -> + if (spec.watcherId in activeWatchers) return@forEach + trezorRepo.startWatcher( + watcherId = spec.watcherId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + ).onSuccess { activeWatchers += spec.watcherId } + } + + (activeWatchers - wantedIds).forEach { staleId -> + activeWatchers -= staleId + trezorRepo.stopWatcher(staleId) + _watcherData.update { it - staleId } + } + } + } + } + + private fun HistoryTransaction.toOnchainActivity(): Activity { + val type = when (direction) { + TxDirection.RECEIVED -> PaymentType.RECEIVED + TxDirection.SENT, TxDirection.SELF_TRANSFER -> PaymentType.SENT + } + val activityTimestamp = timestamp ?: (System.currentTimeMillis() / 1000).toULong() + return Activity.Onchain( + OnchainActivity.create( + id = txid, + txType = type, + txId = txid, + value = amount, + fee = fee ?: 0uL, + address = "", + timestamp = activityTimestamp, + confirmed = confirmations > 0u, + ) + ) + } + + private data class WatcherSpec( + val deviceId: String, + val addressType: String, + val xpub: String, + ) { + val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" + } + + private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) +} + +@Stable +data class HwWallet( + val id: String, + val name: String, + val model: String?, + val transportType: KnownDeviceTransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, +) + +private data class HwWatcherData( + val deviceId: String, + val balanceSats: ULong, + val activities: ImmutableList, +) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 5f7b467167..59c1433ba0 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -48,7 +48,11 @@ import kotlinx.serialization.Serializable import to.bitkit.data.TrezorStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toSettingsString +import to.bitkit.models.toTrezorCoinType import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -641,6 +645,7 @@ class TrezorRepo @Inject constructor( private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices + val previous = existing.find { it.id == deviceInfo.id } val known = KnownDevice( id = deviceInfo.id, name = deviceInfo.name, @@ -649,12 +654,34 @@ class TrezorRepo @Inject constructor( label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, lastConnectedAt = System.currentTimeMillis(), + xpubs = fetchAccountXpubs().ifEmpty { previous?.xpubs ?: emptyMap() }, ) val updated = existing.filter { it.id != known.id } + known saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } } + /** + * Reads account-level extended public keys for every supported address type so a + * watch-only balance can be tracked later without the device present. Failures are + * swallowed per type: a missing xpub just means that address type is not watched. + */ + private suspend fun fetchAccountXpubs(): Map { + val coin = Env.network.toTrezorCoinType() + return ALL_ADDRESS_TYPES.mapNotNull { addressType -> + runCatching { + val xpub = trezorService.getPublicKey( + path = addressType.toAccountDerivationPath(), + coin = coin, + showOnTrezor = false, + ).xpub + addressType.toSettingsString() to xpub + }.onFailure { + Logger.warn("Could not read xpub for '${addressType.toSettingsString()}'", it, context = TAG) + }.getOrNull() + }.toMap() + } + private suspend fun loadKnownDevices(): List = runCatching { trezorStore.loadKnownDevices() }.onFailure { @@ -778,6 +805,8 @@ data class KnownDevice( val label: String?, val model: String?, val lastConnectedAt: Long, + /** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */ + val xpubs: Map = emptyMap(), ) @Serializable diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index a492771acf..f64de6492f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -66,6 +66,7 @@ class WalletRepo @Inject constructor( private val wipeWalletUseCase: WipeWalletUseCase, private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, + private val hwWalletRepo: HwWalletRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -84,6 +85,11 @@ class WalletRepo @Inject constructor( refreshBip21ForEvent(event) } } + repoScope.launch { + hwWalletRepo.totalHardwareSats.collect { hardwareSats -> + _balanceState.update { it.copy(totalHardwareSats = hardwareSats) } + } + } } fun loadFromCache() { @@ -274,7 +280,8 @@ class WalletRepo @Inject constructor( suspend fun syncBalances() { deriveBalanceStateUseCase().onSuccess { balanceState -> runCatching { cacheStore.cacheBalance(balanceState) } - _balanceState.update { balanceState } + // Preserve the live hardware-wallet total; the use case only derives onchain + lightning. + _balanceState.update { balanceState.copy(totalHardwareSats = hwWalletRepo.totalHardwareSats.value) } }.onFailure { if (it !is CancellationException) { Logger.warn("Could not sync balances", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 1f57f085b2..b12dcc2b9e 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -29,6 +29,7 @@ import android.os.Handler import android.os.Looper import android.os.ParcelUuid import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult @@ -159,6 +160,50 @@ class TrezorTransport @Inject constructor( bluetoothManager.adapter } + /** + * Surfaces link loss the per-connection GATT callback misses: the phone's + * Bluetooth being switched off, or a USB device being unplugged. Both feed the + * same [externalDisconnect] flow so the repo clears the connected device and the + * UI connection indicator reflects reality in real time. + */ + private val connectionStateReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + BluetoothAdapter.ACTION_STATE_CHANGED -> onBluetoothStateChanged(intent) + UsbManager.ACTION_USB_DEVICE_DETACHED -> onUsbDeviceDetached(intent) + } + } + } + + private fun onBluetoothStateChanged(intent: Intent) { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state != BluetoothAdapter.STATE_OFF && state != BluetoothAdapter.STATE_TURNING_OFF) return + bleConnections.keys.toList().forEach { path -> + bleConnections[path]?.isConnected = false + emitExternalDisconnect(path) + } + } + + private fun onUsbDeviceDetached(intent: Intent) { + val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + val path = device?.deviceName ?: return + if (path in usbConnections.keys) emitExternalDisconnect(path) + } + + private fun emitExternalDisconnect(path: String) { + if (!userInitiatedCloseSet.remove(path)) { + _externalDisconnect.tryEmit(path) + } + } + + init { + val filter = IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + ContextCompat.registerReceiver(context, connectionStateReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + } + private val usbConnections = ConcurrentHashMap() private val bleConnections = ConcurrentHashMap() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e054b9b657..ccff2c6f8b 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -177,6 +177,7 @@ import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet +import to.bitkit.ui.sheets.HardwareWalletConnectSheet import to.bitkit.ui.sheets.ChangePinSheet import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet @@ -454,6 +455,10 @@ fun ContentView( Sheet.ChangePin -> ChangePinSheet(appViewModel) Sheet.DisablePin -> DisablePinSheet(appViewModel) is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) + is Sheet.HardwareWalletConnect -> HardwareWalletConnectSheet( + sheet = sheet, + onDismiss = { appViewModel.hideSheet() }, + ) is Sheet.Widgets -> { WidgetsSheet( sheet = sheet, diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ffb8d1748b..40bcd6e964 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -34,6 +34,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute +import to.bitkit.ui.sheets.HwConnectRoute import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.WidgetsRoute @@ -57,6 +58,7 @@ sealed interface Sheet { data object ChangePin : Sheet data object DisablePin : Sheet data class Backup(val route: BackupRoute = BackupRoute.ShowMnemonic) : Sheet + data class HardwareWalletConnect(val route: HwConnectRoute = HwConnectRoute.Intro) : Sheet data class Widgets(val route: WidgetsRoute = WidgetsRoute.Gallery) : Sheet data object ActivityDateRangeSelector : Sheet data object ActivityTagSelector : Sheet 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 1af5a07764..bae14ecd27 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 @@ -115,12 +115,15 @@ import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion +import to.bitkit.models.Toast import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.effectiveSize import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel +import to.bitkit.repositories.HwWallet +import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.ui.LocalBalances import to.bitkit.ui.Routes import to.bitkit.ui.components.ActivityBanner @@ -171,6 +174,7 @@ import to.bitkit.ui.screens.widgets.price.PriceCardSmall import to.bitkit.ui.screens.widgets.weather.WeatherCard import to.bitkit.ui.screens.widgets.weather.WeatherCardSmall import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute @@ -214,6 +218,7 @@ fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() @@ -278,6 +283,10 @@ fun HomeScreen( rootNavController.navigateTo(Routes.BuyIntro) } + Suggestion.HARDWARE -> { + appViewModel.showSheet(Sheet.HardwareWalletConnect()) + } + Suggestion.LIGHTNING -> { if (!hasSeenTransferIntro) { rootNavController.navigateToTransferIntro() @@ -367,6 +376,14 @@ fun HomeScreen( onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, + onClickHardwareWallet = { + scope.launch { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Hardware Overview not yet implemented.", + ) + } + }, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -403,6 +420,7 @@ private fun Content( onNavigateToActivityItem: (String) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, + onClickHardwareWallet: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, @@ -530,6 +548,7 @@ private fun Content( onNavigateToActivityItem = onNavigateToActivityItem, onNavigateToSavings = onNavigateToSavings, onNavigateToSpending = onNavigateToSpending, + onClickHardwareWallet = onClickHardwareWallet, ) 1 -> WidgetsPage( @@ -570,6 +589,7 @@ private fun WalletPage( onNavigateToActivityItem: (String) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, + onClickHardwareWallet: () -> Unit, ) { val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() @@ -602,7 +622,7 @@ private fun WalletPage( VerticalSpacer(16.dp) BalanceHeaderView( - sats = balances.totalSats.toLong(), + sats = balances.totalWithHardwareSats.toLong(), showEyeIcon = true, testTag = "TotalBalance", modifier = Modifier @@ -610,7 +630,13 @@ private fun WalletPage( .testTag("TotalBalance") ) VerticalSpacer(32.dp) - BalancesSection(balances, onNavigateToSavings, onNavigateToSpending) + BalancesSection( + balances = balances, + hardwareWallets = homeUiState.hardwareWallets, + onNavigateToSavings = onNavigateToSavings, + onNavigateToSpending = onNavigateToSpending, + onClickHardwareWallet = onClickHardwareWallet, + ) VerticalSpacer(32.dp) if (!homeUiState.showEmptyState) { @@ -670,33 +696,75 @@ private fun WalletPage( @Composable private fun BalancesSection( balances: BalanceState, + hardwareWallets: ImmutableList, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, + onClickHardwareWallet: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + WalletBalanceView( + title = stringResource(R.string.wallet__savings__title), + sats = balances.totalOnchainSats.toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle), + modifier = Modifier + .clickableAlpha(onClick = onNavigateToSavings) + .padding(vertical = 4.dp) + .testTag("ActivitySavings") + ) + VerticalDivider(color = Colors.Gray4) + HorizontalSpacer(16.dp) + WalletBalanceView( + title = stringResource(R.string.wallet__spending__title), + sats = balances.totalLightningSats.toLong(), + icon = painterResource(id = R.drawable.ic_ln_circle), + modifier = Modifier + .clickableAlpha(onClick = onNavigateToSpending) + .padding(vertical = 4.dp) + .testTag("ActivitySpending") + ) + } + + hardwareWallets.forEach { wallet -> + VerticalSpacer(16.dp) + HardwareWalletRow(wallet = wallet, onClick = onClickHardwareWallet) + } + } +} + +@Composable +private fun HardwareWalletRow( + wallet: HwWallet, + onClick: () -> Unit, ) { Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) + .clickableAlpha(onClick = onClick) + .padding(vertical = 4.dp) + .testTag("ActivityHardware") ) { WalletBalanceView( - title = stringResource(R.string.wallet__savings__title), - sats = balances.totalOnchainSats.toLong(), - icon = painterResource(id = R.drawable.ic_btc_circle), - modifier = Modifier - .clickableAlpha(onClick = onNavigateToSavings) - .padding(vertical = 4.dp) - .testTag("ActivitySavings") + title = wallet.name, + sats = wallet.balanceSats.toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle_blue), ) - VerticalDivider(color = Colors.Gray4) - HorizontalSpacer(16.dp) - WalletBalanceView( - title = stringResource(R.string.wallet__spending__title), - sats = balances.totalLightningSats.toLong(), - icon = painterResource(id = R.drawable.ic_ln_circle), - modifier = Modifier - .clickableAlpha(onClick = onNavigateToSpending) - .padding(vertical = 4.dp) - .testTag("ActivitySpending") + Icon( + painter = painterResource( + id = when (wallet.transportType) { + KnownDeviceTransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected + KnownDeviceTransportType.USB -> R.drawable.ic_usb_connected + } + ), + contentDescription = null, + tint = if (wallet.isConnected) Colors.Green else Colors.Gray1, + modifier = Modifier.size(16.dp) ) } } @@ -1387,6 +1455,17 @@ private val previewWeather = WeatherModel( private val previewLatestActivities = previewActivityItems.take(3).toImmutableList() private val previewBanners = ActivityBannerType.entries.map { BannerItem(type = it, title = "") }.toImmutableList() private val previewSuggestions = Suggestion.entries.take(4).toImmutableList() +private val previewHardwareWallets = persistentListOf( + HwWallet( + id = "trezor-1", + name = "Trezor Safe 5", + model = "Safe 5", + transportType = KnownDeviceTransportType.BLUETOOTH, + isConnected = true, + balanceSats = 10_562_411uL, + activities = persistentListOf(), + ), +) @Preview(showSystemUi = true) @Composable @@ -1407,6 +1486,25 @@ private fun PreviewWithActivity() { } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithHardwareWallet() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = previewHardwareWallets, + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy(totalHardwareSats = 10_562_411uL), + ) + TabBar() + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 6efbef038d..2381e07e0b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -14,11 +14,13 @@ import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences import to.bitkit.models.widget.WeatherPreferences +import to.bitkit.repositories.HwWallet import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable data class HomeUiState( val suggestions: ImmutableList = persistentListOf(), + val hardwareWallets: ImmutableList = persistentListOf(), val banners: ImmutableList = persistentListOf(), val showWidgets: Boolean = false, val widgetsWithPosition: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index b06516e4be..6889590cd9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.models.widget.toArticleModel import to.bitkit.models.widget.toBlockModel import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -51,6 +52,7 @@ class HomeViewModel @Inject constructor( private val transferRepo: TransferRepo, private val pubkyRepo: PubkyRepo, private val activityRepo: ActivityRepo, + private val hwWalletRepo: HwWalletRepo, ) : ViewModel() { companion object { @@ -120,6 +122,12 @@ class HomeViewModel @Inject constructor( } } + viewModelScope.launch { + hwWalletRepo.hardwareWallets.collect { wallets -> + _uiState.update { it.copy(hardwareWallets = wallets) } + } + } + @OptIn(ExperimentalCoroutinesApi::class) val hasActivityFlow = activityRepo.activitiesChanged.mapLatest { activityRepo.getActivities(limit = 1u).getOrNull()?.isNotEmpty() == true @@ -312,12 +320,14 @@ class HomeViewModel @Inject constructor( settingsStore.data, transferRepo.activeTransfers, pubkyRepo.isAuthenticated, - ) { balanceState, settings, transfers, profileAuthenticated -> + hwWalletRepo.hardwareWallets, + ) { balanceState, settings, transfers, profileAuthenticated, hardwareWallets -> + val hasHardwareWallet = hardwareWallets.isNotEmpty() val baseSuggestions = when { balanceState.totalLightningSats > 0uL -> - spendingSuggestions(settings, profileAuthenticated) + spendingSuggestions(settings, profileAuthenticated, hasHardwareWallet) balanceState.totalOnchainSats > 0uL -> - savingsOnlySuggestions(settings, transfers, profileAuthenticated) + savingsOnlySuggestions(settings, transfers, profileAuthenticated, hasHardwareWallet) else -> emptyWalletSuggestions(settings, transfers, profileAuthenticated) } val dismissedList = settings.dismissedSuggestions.mapNotNull { it.toSuggestionOrNull() } @@ -329,10 +339,12 @@ class HomeViewModel @Inject constructor( private fun spendingSuggestions( settings: SettingsData, profileAuthenticated: Boolean, + hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled }, Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted }, Suggestion.SHOP, + Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.PROFILE.takeIf { !profileAuthenticated }, Suggestion.SUPPORT, Suggestion.INVITE, @@ -343,12 +355,14 @@ class HomeViewModel @Inject constructor( settings: SettingsData, transfers: List, profileAuthenticated: Boolean, + hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.BACK_UP.takeIf { !settings.backupVerified }, Suggestion.SECURE.takeIf { !settings.isPinEnabled }, Suggestion.LIGHTNING.takeIf { transfers.all { it.type != TransferType.TO_SPENDING } }, + Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.SUPPORT, Suggestion.PROFILE.takeIf { !profileAuthenticated }, Suggestion.INVITE, diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt new file mode 100644 index 0000000000..e2ab61e2d6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt @@ -0,0 +1,68 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.composableWithDefaultTransitions + +/** + * Entry point for the hardware-wallet connect flow opened from the home suggestion + * card. The multi-screen scaffolding (nav host + typed routes) is in place; the four + * real steps land in the dedicated connect-flow subtask. + */ +@Composable +fun HardwareWalletConnectSheet( + sheet: Sheet.HardwareWalletConnect, + onDismiss: () -> Unit, +) { + val navController = rememberNavController() + + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.MEDIUM) + .testTag("hardware_wallet_connect_sheet") + ) { + NavHost( + navController = navController, + startDestination = sheet.route, + ) { + composableWithDefaultTransitions { + HardwareWalletConnectIntro(onClose = onDismiss) + } + } + } +} + +@Composable +private fun HardwareWalletConnectIntro(onClose: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + SheetTopBar(titleText = "Connect Hardware", onBack = onClose) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + BodyM(text = "Hardware wallet connect flow is not yet implemented.", color = Colors.White64) + VerticalSpacer(24.dp) + PrimaryButton(text = "Close", onClick = onClose) + VerticalSpacer(16.dp) + } + } +} + +sealed interface HwConnectRoute { + @Serializable + data object Intro : HwConnectRoute +} diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 24ca5fb22e..0de4fd0d7a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -29,9 +29,11 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer +import to.bitkit.ext.timestamp import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -42,6 +44,7 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, + hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -55,7 +58,22 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() private val _latestActivities = MutableStateFlow?>(null) - val latestActivities = _latestActivities.asStateFlow() + + // Merge the device's watch-only hardware-wallet activity into the home list, + // newest first, capped at the same limit as the on-chain/lightning list. + val latestActivities: StateFlow?> = combine( + _latestActivities, + hwWalletRepo.hardwareActivities, + ) { localActivities, hardwareActivities -> + if (localActivities == null && hardwareActivities.isEmpty()) { + null + } else { + (localActivities.orEmpty() + hardwareActivities) + .sortedByDescending { it.timestamp() } + .take(SIZE_LATEST) + .toImmutableList() + } + }.stateInScope(null) val contacts: StateFlow> = combine( diff --git a/app/src/main/res/drawable-nodpi/trezor_device.webp b/app/src/main/res/drawable-nodpi/trezor_device.webp new file mode 100644 index 0000000000000000000000000000000000000000..349867cef203142703f24824f67f877596a0e83a GIT binary patch literal 1022 zcmV00E$D+qP-j zn%A~%+qSYB*+!;_5kJ7sFMJUr+wVoTZCkAcYp%7&I^}*qL`*=sUUq2Pty5DOvF7yE zr@#E}@Be>)_d>hcI&Zr8(Vwb_>f8P6nR@9rltTFpb@aXYFBKd7wO?GH&&0}PJQCLT z9jl{Kcs=Y!gZX!Q&``5fKt=UT&@>e*7m9olG~sW{$*3U0nwa!@8D;?ii zz8ZcAS{H>nAg1MaHEf7UcLpi$4qDq7Io%V~?CXZ|;D(@dEdbzu7!$SfzXr$(|8|Q? zFI6F{B2T379XSLPi@@!1t)Gyrf+_;+iA$4y0`nk(=;9jEa*B$8Sp24W9cj^rWEHW< zBdOj|uYg#2drfKJHO!N_H`RaqZz&Z2xT$9oNV7f#M1Q$FG&Rjm7k_~t@7UZSHQiQI z`ZP<|K~_*WAS40+0FVp-odGH^05AYPF%pPEq9Gv{90p_{0|c~y3FtmhC4lUL#@s|{ zBbrZ*%|d@yJuUif+TL7{3|~?(FSwt?!Efi4mz1pfWyh36YNSX;oo=F zLlo&uw1Y*)J3F!EC2xh|JbshfTGRu zOyTe_JRUpdRO1{qe#vTu{N&s%A!rfQ|K~7t_7gC{dm?|=9^c!`I@6@Lr*M$Ci!P(lV_s*LBM??fslx zvlD&4?ts#nY57-Xob*v^eyLd_#OJd#<;N}io%zB>Rucupc9Dkftr)y^GUe0-9j>PjeN4YeisY~W04sg;&Hw-a literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_bluetooth_connected.xml b/app/src/main/res/drawable/ic_bluetooth_connected.xml new file mode 100644 index 0000000000..720bfd6c8b --- /dev/null +++ b/app/src/main/res/drawable/ic_bluetooth_connected.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btc_circle_blue.xml b/app/src/main/res/drawable/ic_btc_circle_blue.xml new file mode 100644 index 0000000000..e2017b6f38 --- /dev/null +++ b/app/src/main/res/drawable/ic_btc_circle_blue.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_usb_connected.xml b/app/src/main/res/drawable/ic_usb_connected.xml new file mode 100644 index 0000000000..3121cc7b3c --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_connected.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ba0b7a597..5bd3f9447a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ Back up Buy some bitcoin Buy + Connect device + Hardware Share Bitkit Invite Instant payments diff --git a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt new file mode 100644 index 0000000000..dfb3eff043 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals + +class BalanceStateTest { + + @Test + fun `totalSats sums onchain and lightning`() { + val state = BalanceState(totalOnchainSats = 100uL, totalLightningSats = 50uL) + assertEquals(150uL, state.totalSats) + } + + @Test + fun `totalWithHardwareSats adds the hardware balance on top of the total`() { + val state = BalanceState( + totalOnchainSats = 100uL, + totalLightningSats = 50uL, + totalHardwareSats = 25uL, + ) + assertEquals(175uL, state.totalWithHardwareSats) + } + + @Test + fun `totalWithHardwareSats equals totalSats when there is no hardware balance`() { + val state = BalanceState(totalOnchainSats = 100uL, totalLightningSats = 50uL) + assertEquals(state.totalSats, state.totalWithHardwareSats) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt new file mode 100644 index 0000000000..1cfced2fe6 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -0,0 +1,130 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.TxDirection +import com.synonym.bitkitcore.WalletBalance +import com.synonym.bitkitcore.WatcherEvent +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.TrezorData +import to.bitkit.data.TrezorStore +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +class HwWalletRepoTest : BaseUnitTest() { + + private val trezorRepo = mock() + private val trezorStore = mock() + + private lateinit var storeData: MutableStateFlow + private lateinit var trezorState: MutableStateFlow + private lateinit var watcherEvents: MutableSharedFlow> + + private val device = KnownDevice( + id = "dev1", + name = null, + path = "ble:AA:BB", + transportType = KnownDeviceTransportType.BLUETOOTH, + label = "Trezor", + model = "Safe 5", + lastConnectedAt = 0L, + ) + + @Before + fun setUp() { + storeData = MutableStateFlow(TrezorData(knownDevices = listOf(device))) + trezorState = MutableStateFlow(TrezorState()) + watcherEvents = MutableSharedFlow(extraBufferCapacity = 8) + whenever(trezorStore.data).thenReturn(storeData) + whenever(trezorRepo.state).thenReturn(trezorState) + whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + } + + @Test + fun `lists a known device with zero balance before any watcher event`() = test { + val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + + val wallet = sut.hardwareWallets.value.single() + assertEquals("dev1", wallet.id) + assertEquals("Trezor", wallet.name) + assertEquals(0uL, wallet.balanceSats) + assertEquals(0uL, sut.totalHardwareSats.value) + } + + @Test + fun `transactions changed event sets device balance and maps activity`() = test { + val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 10_562_411uL), + transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + val wallet = sut.hardwareWallets.value.single() + assertEquals(10_562_411uL, wallet.balanceSats) + assertEquals(10_562_411uL, sut.totalHardwareSats.value) + assertEquals(1, wallet.activities.size) + assertEquals(1, sut.hardwareActivities.value.size) + assertEquals(Activity.Onchain::class, wallet.activities.single()::class) + } + + @Test + fun `balances from multiple address-type watchers are summed per device`() = test { + val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|taproot" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 50uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(150uL, sut.hardwareWallets.value.single().balanceSats) + assertEquals(150uL, sut.totalHardwareSats.value) + } + + private fun walletBalance(total: ULong) = WalletBalance( + confirmed = total, + immature = 0uL, + trustedPending = 0uL, + untrustedPending = 0uL, + spendable = total, + total = total, + ) + + private fun receivedTransaction(amount: ULong) = HistoryTransaction( + txid = "t1", + received = amount, + sent = 0uL, + net = amount.toLong(), + fee = null, + amount = amount, + direction = TxDirection.RECEIVED, + blockHeight = 850_000u, + timestamp = 1_700_000_000uL, + confirmations = 3u, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 27144b7162..c5ed5d1cc4 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -54,6 +54,7 @@ class WalletRepoTest : BaseUnitTest() { private val transferRepo = mock() private val onchainService = mock() private val activityRepo = mock() + private val hwWalletRepo = mock() companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -86,6 +87,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = ADDRESS))) whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(hwWalletRepo.totalHardwareSats).thenReturn(MutableStateFlow(0uL)) whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) @@ -134,6 +136,7 @@ class WalletRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, transferRepo = transferRepo, activityRepo = activityRepo, + hwWalletRepo = hwWalletRepo, ) @Test diff --git a/changelog.d/next/998.added.md b/changelog.d/next/998.added.md new file mode 100644 index 0000000000..3cdfb1761a --- /dev/null +++ b/changelog.d/next/998.added.md @@ -0,0 +1 @@ +Show paired Trezor hardware wallet balances and activity on the wallet home screen. From 51b38988116142e1ab6fb1b2fdcad52df691ff2b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 13:11:23 +0200 Subject: [PATCH 02/37] chore: rename changelog fragment --- changelog.d/next/{998.added.md => 999.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{998.added.md => 999.added.md} (100%) diff --git a/changelog.d/next/998.added.md b/changelog.d/next/999.added.md similarity index 100% rename from changelog.d/next/998.added.md rename to changelog.d/next/999.added.md From 477575adaa5368a7b045b8a74b9e6f270ee79df8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 14:18:35 +0200 Subject: [PATCH 03/37] fix: hw wallets 2-column grid on home --- .../bitkit/ui/components/WalletBalanceView.kt | 15 ++- .../bitkit/ui/screens/wallets/HomeScreen.kt | 118 ++++++++++++++---- 2 files changed, 106 insertions(+), 27 deletions(-) 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 656167485f..12afac6869 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -45,6 +45,7 @@ fun RowScope.WalletBalanceView( icon: Painter, modifier: Modifier = Modifier, currencies: CurrencyState = LocalCurrencies.current, + titleTrailing: @Composable RowScope.() -> Unit = {}, ) { val isPreview = LocalInspectionMode.current if (isPreview) { @@ -63,6 +64,7 @@ fun RowScope.WalletBalanceView( primaryDisplay = PrimaryDisplay.BITCOIN, displayUnit = BitcoinDisplayUnit.MODERN, hideBalance = false, + titleTrailing = titleTrailing, ) } @@ -83,6 +85,7 @@ fun RowScope.WalletBalanceView( primaryDisplay = primaryDisplay, displayUnit = displayUnit, hideBalance = hideBalance, + titleTrailing = titleTrailing, ) } @@ -95,16 +98,20 @@ private fun RowScope.Content( primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, hideBalance: Boolean, + titleTrailing: @Composable RowScope.() -> Unit = {}, ) { Column( modifier = Modifier .weight(1f) .then(modifier) ) { - Text13Up( - text = title, - color = Colors.White64, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text13Up( + text = title, + color = Colors.White64, + ) + titleTrailing() + } VerticalSpacer(8.dp) converted?.let { converted -> 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 bae14ecd27..41e0fbe97d 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -131,6 +132,7 @@ import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.Headline24 import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PubkyImage @@ -729,32 +731,44 @@ private fun BalancesSection( ) } - hardwareWallets.forEach { wallet -> + // Hardware wallets flow into a 2-column grid: a second device fills the + // bottom-right column, additional devices wrap onto new rows. + hardwareWallets.chunked(2).forEach { rowWallets -> VerticalSpacer(16.dp) - HardwareWalletRow(wallet = wallet, onClick = onClickHardwareWallet) + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + HardwareWalletCell(wallet = rowWallets[0], onClick = onClickHardwareWallet) + VerticalDivider(color = Colors.Gray4) + HorizontalSpacer(16.dp) + val second = rowWallets.getOrNull(1) + if (second != null) { + HardwareWalletCell(wallet = second, onClick = onClickHardwareWallet) + } else { + FillWidth() + } + } } } } @Composable -private fun HardwareWalletRow( +private fun RowScope.HardwareWalletCell( wallet: HwWallet, onClick: () -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, + WalletBalanceView( + title = wallet.name, + sats = wallet.balanceSats.toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle_blue), modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) .clickableAlpha(onClick = onClick) .padding(vertical = 4.dp) .testTag("ActivityHardware") ) { - WalletBalanceView( - title = wallet.name, - sats = wallet.balanceSats.toLong(), - icon = painterResource(id = R.drawable.ic_btc_circle_blue), - ) + HorizontalSpacer(4.dp) Icon( painter = painterResource( id = when (wallet.transportType) { @@ -1455,16 +1469,32 @@ private val previewWeather = WeatherModel( private val previewLatestActivities = previewActivityItems.take(3).toImmutableList() private val previewBanners = ActivityBannerType.entries.map { BannerItem(type = it, title = "") }.toImmutableList() private val previewSuggestions = Suggestion.entries.take(4).toImmutableList() -private val previewHardwareWallets = persistentListOf( - HwWallet( - id = "trezor-1", - name = "Trezor Safe 5", - model = "Safe 5", - transportType = KnownDeviceTransportType.BLUETOOTH, - isConnected = true, - balanceSats = 10_562_411uL, - activities = persistentListOf(), - ), +private val previewHardwareWalletBt = HwWallet( + id = "trezor-1", + name = "Trezor Safe 5", + model = "Safe 5", + transportType = KnownDeviceTransportType.BLUETOOTH, + isConnected = true, + balanceSats = 10_562_411uL, + activities = persistentListOf(), +) +private val previewHardwareWalletUsb = HwWallet( + id = "trezor-2", + name = "Trezor Model T", + model = "Model T", + transportType = KnownDeviceTransportType.USB, + isConnected = false, + balanceSats = 2_735_180uL, + activities = persistentListOf(), +) +private val previewHardwareWalletThird = HwWallet( + id = "trezor-3", + name = "Trezor Safe 3", + model = "Safe 3", + transportType = KnownDeviceTransportType.BLUETOOTH, + isConnected = true, + balanceSats = 500_000uL, + activities = persistentListOf(), ) @Preview(showSystemUi = true) @@ -1494,7 +1524,7 @@ private fun PreviewWithHardwareWallet() { Content( isRefreshing = false, homeUiState = HomeUiState( - hardwareWallets = previewHardwareWallets, + hardwareWallets = persistentListOf(previewHardwareWalletBt), ), drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), latestActivities = previewLatestActivities, @@ -1505,6 +1535,48 @@ private fun PreviewWithHardwareWallet() { } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithTwoHardwareWallets() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = persistentListOf(previewHardwareWalletBt, previewHardwareWalletUsb), + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy(totalHardwareSats = 13_297_591uL), + ) + TabBar() + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewWithThreeHardwareWallets() { + AppThemeSurface { + Box { + Content( + isRefreshing = false, + homeUiState = HomeUiState( + hardwareWallets = persistentListOf( + previewHardwareWalletBt, + previewHardwareWalletUsb, + previewHardwareWalletThird, + ), + ), + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + latestActivities = previewLatestActivities, + balances = previewBalances.copy(totalHardwareSats = 13_797_591uL), + ) + TabBar() + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { From f8e39e6e6e1d857de94310505fc4ea24ea3d7dad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 14:29:47 +0200 Subject: [PATCH 04/37] fix: watch only monitored hw address types --- .../to/bitkit/repositories/HwWalletRepo.kt | 19 ++++++-- .../bitkit/repositories/HwWalletRepoTest.kt | 44 +++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 5ba1f4e085..92f0fc437b 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -17,10 +17,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.data.SettingsStore import to.bitkit.data.TrezorStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env @@ -43,6 +45,7 @@ import javax.inject.Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, private val trezorStore: TrezorStore, + private val settingsStore: SettingsStore, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -100,9 +103,19 @@ class HwWalletRepo @Inject constructor( private fun syncWatchers() { scope.launch { - trezorStore.data.collect { data -> - val wanted = data.knownDevices.flatMap { device -> - device.xpubs.map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } + combine( + trezorStore.data, + settingsStore.data.map { it.addressTypesToMonitor.toSet() }.distinctUntilChanged(), + ) { data, monitoredTypes -> + data.knownDevices to monitoredTypes + }.collect { (knownDevices, monitoredTypes) -> + // Only watch the address types the user monitors (Settings > Advanced > Address Type), + // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so + // toggling a type on later starts its watcher without reconnecting the device. + val wanted = knownDevices.flatMap { device -> + device.xpubs + .filterKeys { it in monitoredTypes } + .map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } } val wantedIds = wanted.map { it.watcherId }.toSet() diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 1cfced2fe6..af849119aa 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -10,8 +10,15 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.data.TrezorData import to.bitkit.data.TrezorStore import to.bitkit.test.BaseUnitTest @@ -21,8 +28,10 @@ class HwWalletRepoTest : BaseUnitTest() { private val trezorRepo = mock() private val trezorStore = mock() + private val settingsStore = mock() private lateinit var storeData: MutableStateFlow + private lateinit var settingsData: MutableStateFlow private lateinit var trezorState: MutableStateFlow private lateinit var watcherEvents: MutableSharedFlow> @@ -39,16 +48,20 @@ class HwWalletRepoTest : BaseUnitTest() { @Before fun setUp() { storeData = MutableStateFlow(TrezorData(knownDevices = listOf(device))) + settingsData = MutableStateFlow(SettingsData()) trezorState = MutableStateFlow(TrezorState()) watcherEvents = MutableSharedFlow(extraBufferCapacity = 8) whenever(trezorStore.data).thenReturn(storeData) + whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) } + private fun createRepo() = HwWalletRepo(trezorRepo, trezorStore, settingsStore, testDispatcher) + @Test fun `lists a known device with zero balance before any watcher event`() = test { - val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + val sut = createRepo() val wallet = sut.hardwareWallets.value.single() assertEquals("dev1", wallet.id) @@ -59,7 +72,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `transactions changed event sets device balance and maps activity`() = test { - val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + val sut = createRepo() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -81,7 +94,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `balances from multiple address-type watchers are summed per device`() = test { - val sut = HwWalletRepo(trezorRepo, trezorStore, testDispatcher) + val sut = createRepo() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -98,7 +111,7 @@ class HwWalletRepoTest : BaseUnitTest() { transactions = emptyList(), txCount = 0u, blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, + accountType = AccountType.TAPROOT, ) ) @@ -106,6 +119,29 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(150uL, sut.totalHardwareSats.value) } + @Test + fun `starts watchers only for the address types the user monitors`() = test { + storeData.value = TrezorData( + knownDevices = listOf( + device.copy( + xpubs = mapOf( + "nativeSegwit" to "zpubNS", + "taproot" to "zpubTR", + "legacy" to "xpubLG", + ) + ) + ) + ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + createRepo() + + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull()) + verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull()) + verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull()) + } + private fun walletBalance(total: ULong) = WalletBalance( confirmed = total, immature = 0uL, From e3ea871b672ac679baf4ca72e0d60e833be66a5d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 14:32:41 +0200 Subject: [PATCH 05/37] fix: saturate total-with-hardware balance sum --- app/src/main/java/to/bitkit/models/BalanceState.kt | 2 +- app/src/test/java/to/bitkit/models/BalanceStateTest.kt | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 0a09e0b271..f643b1056a 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -17,5 +17,5 @@ data class BalanceState( ) { val totalSats get() = totalOnchainSats + totalLightningSats - val totalWithHardwareSats get() = totalSats + totalHardwareSats + val totalWithHardwareSats get() = totalSats.safe() + totalHardwareSats.safe() } diff --git a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt index dfb3eff043..7060e98685 100644 --- a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt +++ b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt @@ -26,4 +26,10 @@ class BalanceStateTest { val state = BalanceState(totalOnchainSats = 100uL, totalLightningSats = 50uL) assertEquals(state.totalSats, state.totalWithHardwareSats) } + + @Test + fun `totalWithHardwareSats saturates instead of overflowing`() { + val state = BalanceState(totalLightningSats = ULong.MAX_VALUE, totalHardwareSats = 10uL) + assertEquals(ULong.MAX_VALUE, state.totalWithHardwareSats) + } } From a4dbd430e4dbbb29bfdb1d49e8df522170c3592d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 16:49:36 +0200 Subject: [PATCH 06/37] docs: reference test files by name in pr notes --- .agents/commands/pr.md | 9 +++++---- AGENTS.md | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/commands/pr.md b/.agents/commands/pr.md index 4c49f680dc..7660aef2db 100644 --- a/.agents/commands/pr.md +++ b/.agents/commands/pr.md @@ -136,7 +136,8 @@ When the user provides custom instructions after `--`: #### Automated Checks ``` - Keep local verification commands, Gradle tasks, detekt, lint, unit tests, build passes, cargo test, cargo clippy, npm test, typecheck, CI coverage, or similar automated checks out of `#### Manual Tests`; summarize them under `#### Automated Checks` when they add useful context. -- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed with file paths and a short explanation. +- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed, each with the test file name and a short explanation. +- Reference test files by bare file name only (e.g. `HwWalletRepoTest.kt`), never the full path. Only when two referenced test files share the same name, prefix the shortest leading path segment(s) that disambiguate them (e.g. `repositories/FooTest.kt` vs `viewmodels/FooTest.kt`). - For removed automated coverage, state why it was removed. - Do not list standard CI or PR bot commands as checkbox items just because they run for every PR. If standard CI coverage is worth mentioning, summarize it in one sentence. - List raw commands only when they were run locally, are non-standard, use special flags or environment values, validate workflow behavior, or explain a meaningful verification gap. @@ -184,9 +185,9 @@ Concrete style target: - [ ] **5b.** back: returns to Connections List. - [ ] **6.** `regression:` Channel Detail → tap Close Connection: works. #### Automated Checks -- Unit tests added: cover invoice timeout handling in `app/src/test/.../SendInvoiceTest.kt`. -- Unit tests modified: update channel navigation assertions in `app/src/test/.../ChannelDetailTest.kt`. -- Test coverage removed: delete stale mock-only assertions from `app/src/test/.../OldFlowTest.kt` because the flow no longer exists. +- Unit tests added: cover invoice timeout handling in `SendInvoiceTest.kt`. +- Unit tests modified: update channel navigation assertions in `ChannelDetailTest.kt`. +- Test coverage removed: delete stale mock-only assertions from `OldFlowTest.kt` because the flow no longer exists. - CI: standard compile, unit test, and detekt checks run by the PR bot. ``` diff --git a/AGENTS.md b/AGENTS.md index 44f2bc3cc8..51ae68915c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS add new localizable string resources in alphabetical order in `strings.xml` - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms - ALWAYS use template in `.github/pull_request_template.md` for PR descriptions +- ALWAYS reference test files in PR descriptions/QA Notes by bare file name only (e.g. `HwWalletRepoTest.kt`), NEVER the full path; only when two referenced test files share the same name, prefix the shortest leading path segment(s) that disambiguate them (e.g. `repositories/FooTest.kt` vs `viewmodels/FooTest.kt`) - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run {}` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names From e6cef832bf4df0dc542331374ee21201d21e7fe6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 9 Jun 2026 23:33:08 +0200 Subject: [PATCH 07/37] refactor: address hw wallet review feedback --- .../data/{TrezorStore.kt => HwWalletStore.kt} | 16 +++--- ...erializer.kt => HwWalletDataSerializer.kt} | 14 ++--- .../java/to/bitkit/models/BalanceState.kt | 8 ++- .../main/java/to/bitkit/models/HwWallet.kt | 39 +++++++++++++ .../to/bitkit/repositories/HwWalletRepo.kt | 45 +++++++-------- .../java/to/bitkit/repositories/TrezorRepo.kt | 46 +++++++-------- .../java/to/bitkit/repositories/WalletRepo.kt | 8 +-- .../services/ConnectionStateReceiver.kt | 47 +++++++++++++++ .../to/bitkit/services/TrezorTransport.kt | 46 +++++---------- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../java/to/bitkit/ui/components/SheetHost.kt | 4 +- .../ui/screens/trezor/DeviceListSection.kt | 10 ++-- .../ui/screens/trezor/TrezorPreviewData.kt | 6 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 42 ++++++++++---- .../bitkit/ui/screens/wallets/HomeUiState.kt | 2 +- .../ui/screens/wallets/HomeViewModel.kt | 4 +- .../activity/components/ActivityIcon.kt | 22 +++++-- .../activity/components/ActivityListSimple.kt | 5 ++ .../activity/components/ActivityRow.kt | 9 ++- ...eWalletConnectSheet.kt => ConnectSheet.kt} | 16 +++--- .../main/java/to/bitkit/ui/theme/Colors.kt | 1 + .../usecases/DeriveBalanceStateUseCase.kt | 4 ++ .../viewmodels/ActivityListViewModel.kt | 2 +- .../java/to/bitkit/models/BalanceStateTest.kt | 18 +++++- .../bitkit/repositories/HwWalletRepoTest.kt | 57 +++++++++++++------ .../to/bitkit/repositories/TrezorRepoTest.kt | 31 +++++----- .../to/bitkit/repositories/WalletRepoTest.kt | 3 +- .../usecases/DeriveBalanceStateUseCaseTest.kt | 5 ++ 28 files changed, 335 insertions(+), 179 deletions(-) rename app/src/main/java/to/bitkit/data/{TrezorStore.kt => HwWalletStore.kt} (75%) rename app/src/main/java/to/bitkit/data/serializers/{TrezorDataSerializer.kt => HwWalletDataSerializer.kt} (53%) create mode 100644 app/src/main/java/to/bitkit/models/HwWallet.kt create mode 100644 app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt rename app/src/main/java/to/bitkit/ui/sheets/{HardwareWalletConnectSheet.kt => ConnectSheet.kt} (83%) diff --git a/app/src/main/java/to/bitkit/data/TrezorStore.kt b/app/src/main/java/to/bitkit/data/HwWalletStore.kt similarity index 75% rename from app/src/main/java/to/bitkit/data/TrezorStore.kt rename to app/src/main/java/to/bitkit/data/HwWalletStore.kt index 2757cd1dec..79f0cc58cc 100644 --- a/app/src/main/java/to/bitkit/data/TrezorStore.kt +++ b/app/src/main/java/to/bitkit/data/HwWalletStore.kt @@ -9,25 +9,25 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import to.bitkit.data.serializers.TrezorDataSerializer +import to.bitkit.data.serializers.HwWalletDataSerializer import to.bitkit.di.IoDispatcher import to.bitkit.repositories.KnownDevice import javax.inject.Inject import javax.inject.Singleton -private val Context.trezorDataStore: DataStore by dataStore( +private val Context.hwWalletDataStore: DataStore by dataStore( fileName = "trezor_device.json", - serializer = TrezorDataSerializer + serializer = HwWalletDataSerializer ) @Singleton -class TrezorStore @Inject constructor( +class HwWalletStore @Inject constructor( @ApplicationContext private val context: Context, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { - private val store = context.trezorDataStore + private val store = context.hwWalletDataStore - val data: Flow = store.data + val data: Flow = store.data suspend fun loadKnownDevices(): List = withContext(ioDispatcher) { store.data.first().knownDevices @@ -39,12 +39,12 @@ class TrezorStore @Inject constructor( } suspend fun reset() = withContext(ioDispatcher) { - store.updateData { TrezorData() } + store.updateData { HwWalletData() } Unit } } @Serializable -data class TrezorData( +data class HwWalletData( val knownDevices: List = emptyList(), ) diff --git a/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt similarity index 53% rename from app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt rename to app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt index 7a93a59f65..6f84047a11 100644 --- a/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt +++ b/app/src/main/java/to/bitkit/data/serializers/HwWalletDataSerializer.kt @@ -2,27 +2,27 @@ package to.bitkit.data.serializers import androidx.datastore.core.Serializer import kotlinx.serialization.SerializationException -import to.bitkit.data.TrezorData +import to.bitkit.data.HwWalletData import to.bitkit.di.json import to.bitkit.utils.Logger import java.io.InputStream import java.io.OutputStream -object TrezorDataSerializer : Serializer { - private const val TAG = "TrezorDataSerializer" +object HwWalletDataSerializer : Serializer { + private const val TAG = "HwWalletDataSerializer" - override val defaultValue: TrezorData = TrezorData() + override val defaultValue: HwWalletData = HwWalletData() - override suspend fun readFrom(input: InputStream): TrezorData { + override suspend fun readFrom(input: InputStream): HwWalletData { return try { json.decodeFromString(input.readBytes().decodeToString()) } catch (e: SerializationException) { - Logger.error("Deserialize Trezor data failed", e, context = TAG) + Logger.error("Deserialize hardware wallet data failed", e, context = TAG) defaultValue } } - override suspend fun writeTo(t: TrezorData, output: OutputStream) { + override suspend fun writeTo(t: HwWalletData, output: OutputStream) { output.write(json.encodeToString(t).encodeToByteArray()) } } diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index f643b1056a..9bfb8b4cd1 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -1,9 +1,9 @@ package to.bitkit.models -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable -@Immutable +@Stable @Serializable data class BalanceState( val totalOnchainSats: ULong = 0uL, @@ -13,9 +13,11 @@ data class BalanceState( val maxSendOnchainSats: ULong = 0uL, val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, - val totalHardwareSats: ULong = 0uL, + val hardwareWallets: List = emptyList(), ) { val totalSats get() = totalOnchainSats + totalLightningSats + val totalHardwareSats get() = hardwareWallets.fold(0uL) { acc, wallet -> acc.safe() + wallet.sats.safe() } + val totalWithHardwareSats get() = totalSats.safe() + totalHardwareSats.safe() } diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt new file mode 100644 index 0000000000..ff0fd8d0a3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWallet.kt @@ -0,0 +1,39 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.Activity +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** A paired hardware wallet tracked as a watch-only balance. */ +@Stable +data class HwWallet( + val id: String, + val name: String, + val model: String?, + val transportType: HwTransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, +) + +/** Serializable per-device balance snapshot carried by [BalanceState]. */ +@Immutable +@Serializable +data class HwWalletBalance( + val id: String, + val sats: ULong, +) + +@Serializable +enum class HwTransportType { + @SerialName("bluetooth") + BLUETOOTH, + + @SerialName("usb") + USB, +} + +fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 92f0fc437b..b4a9e90824 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -1,6 +1,5 @@ package to.bitkit.repositories -import androidx.compose.runtime.Stable import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.OnchainActivity @@ -22,16 +21,19 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore -import to.bitkit.data.TrezorStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.create +import to.bitkit.models.HwWallet import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime /** * Production hardware-wallet business layer. Tracks paired Trezor devices as @@ -41,11 +43,13 @@ import javax.inject.Singleton * Built on top of [TrezorRepo], which owns the device list, connect orchestration * and the underlying watcher transport. */ +@OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, - private val trezorStore: TrezorStore, + private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, + private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -57,8 +61,8 @@ class HwWalletRepo @Inject constructor( private val activeWatchers = mutableSetOf() private val _watcherData = MutableStateFlow>(emptyMap()) - val hardwareWallets: StateFlow> = combine( - trezorStore.data, + val wallets: StateFlow> = combine( + hwWalletStore.data, trezorRepo.state, _watcherData, ) { data, trezorState, watcherData -> @@ -76,11 +80,11 @@ class HwWalletRepo @Inject constructor( }.toImmutableList() }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) - val totalHardwareSats: StateFlow = hardwareWallets + val totalSats: StateFlow = wallets .map { wallets -> wallets.fold(0uL) { acc, wallet -> acc + wallet.balanceSats } } .stateIn(scope, SharingStarted.Eagerly, 0uL) - val hardwareActivities: StateFlow> = hardwareWallets + val activities: StateFlow> = wallets .map { wallets -> wallets.flatMap { it.activities }.toImmutableList() } .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) @@ -93,7 +97,7 @@ class HwWalletRepo @Inject constructor( scope.launch { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect - val activities = event.transactions.map { it.toOnchainActivity() }.toImmutableList() + val activities = event.transactions.map { it.toOnchainActivity(clock) }.toImmutableList() _watcherData.update { it + (watcherId to HwWatcherData(watcherId.toDeviceId(), event.balance.total, activities)) } @@ -104,7 +108,7 @@ class HwWalletRepo @Inject constructor( private fun syncWatchers() { scope.launch { combine( - trezorStore.data, + hwWalletStore.data, settingsStore.data.map { it.addressTypesToMonitor.toSet() }.distinctUntilChanged(), ) { data, monitoredTypes -> data.knownDevices to monitoredTypes @@ -112,14 +116,14 @@ class HwWalletRepo @Inject constructor( // Only watch the address types the user monitors (Settings > Advanced > Address Type), // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so // toggling a type on later starts its watcher without reconnecting the device. - val wanted = knownDevices.flatMap { device -> + val filtered = knownDevices.flatMap { device -> device.xpubs .filterKeys { it in monitoredTypes } .map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } } - val wantedIds = wanted.map { it.watcherId }.toSet() + val filteredIds = filtered.map { it.watcherId }.toSet() - wanted.forEach { spec -> + filtered.forEach { spec -> if (spec.watcherId in activeWatchers) return@forEach trezorRepo.startWatcher( watcherId = spec.watcherId, @@ -129,7 +133,7 @@ class HwWalletRepo @Inject constructor( ).onSuccess { activeWatchers += spec.watcherId } } - (activeWatchers - wantedIds).forEach { staleId -> + (activeWatchers - filteredIds).forEach { staleId -> activeWatchers -= staleId trezorRepo.stopWatcher(staleId) _watcherData.update { it - staleId } @@ -138,12 +142,12 @@ class HwWalletRepo @Inject constructor( } } - private fun HistoryTransaction.toOnchainActivity(): Activity { + private fun HistoryTransaction.toOnchainActivity(clock: Clock): Activity { val type = when (direction) { TxDirection.RECEIVED -> PaymentType.RECEIVED TxDirection.SENT, TxDirection.SELF_TRANSFER -> PaymentType.SENT } - val activityTimestamp = timestamp ?: (System.currentTimeMillis() / 1000).toULong() + val activityTimestamp = timestamp ?: clock.now().epochSeconds.toULong() return Activity.Onchain( OnchainActivity.create( id = txid, @@ -169,17 +173,6 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } -@Stable -data class HwWallet( - val id: String, - val name: String, - val model: String?, - val transportType: KnownDeviceTransportType, - val isConnected: Boolean, - val balanceSats: ULong, - val activities: ImmutableList, -) - private data class HwWatcherData( val deviceId: String, val balanceSats: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 59c1433ba0..afb411865e 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -43,12 +43,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import to.bitkit.data.TrezorStore +import to.bitkit.data.HwWalletStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.nowMs import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.HwTransportType import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString @@ -63,16 +64,20 @@ import to.bitkit.utils.Logger import java.io.File import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.Network as BitkitCoreNetwork -@Suppress("TooManyFunctions") +@OptIn(ExperimentalTime::class) +@Suppress("TooManyFunctions", "LongParameterList") @Singleton class TrezorRepo @Inject constructor( @ApplicationContext private val context: Context, private val trezorService: TrezorService, private val trezorTransport: TrezorTransport, private val trezorUiHandler: TrezorUiHandler, - private val trezorStore: TrezorStore, + private val hwWalletStore: HwWalletStore, + private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -650,10 +655,10 @@ class TrezorRepo @Inject constructor( id = deviceInfo.id, name = deviceInfo.name, path = deviceInfo.path, - transportType = deviceInfo.transportType.toKnownTransportType(), + transportType = deviceInfo.transportType.toHwTransportType(), label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, - lastConnectedAt = System.currentTimeMillis(), + lastConnectedAt = clock.nowMs(), xpubs = fetchAccountXpubs().ifEmpty { previous?.xpubs ?: emptyMap() }, ) val updated = existing.filter { it.id != known.id } + known @@ -671,7 +676,7 @@ class TrezorRepo @Inject constructor( return ALL_ADDRESS_TYPES.mapNotNull { addressType -> runCatching { val xpub = trezorService.getPublicKey( - path = addressType.toAccountDerivationPath(), + path = addressType.toAccountDerivationPath(network = Env.network), coin = coin, showOnTrezor = false, ).xpub @@ -683,14 +688,14 @@ class TrezorRepo @Inject constructor( } private suspend fun loadKnownDevices(): List = runCatching { - trezorStore.loadKnownDevices() + hwWalletStore.loadKnownDevices() }.onFailure { Logger.error("Failed to load known devices", it, context = TAG) }.getOrDefault(emptyList()) private suspend fun saveKnownDevices(devices: List) { runCatching { - trezorStore.saveKnownDevices(devices) + hwWalletStore.saveKnownDevices(devices) }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } @@ -801,7 +806,7 @@ data class KnownDevice( val id: String, val name: String?, val path: String, - val transportType: KnownDeviceTransportType, + val transportType: HwTransportType, val label: String?, val model: String?, val lastConnectedAt: Long, @@ -809,21 +814,12 @@ data class KnownDevice( val xpubs: Map = emptyMap(), ) -@Serializable -enum class KnownDeviceTransportType { - @SerialName("bluetooth") - BLUETOOTH, - - @SerialName("usb") - USB, -} - -private fun TrezorTransportType.toKnownTransportType(): KnownDeviceTransportType = when (this) { - TrezorTransportType.BLUETOOTH -> KnownDeviceTransportType.BLUETOOTH - TrezorTransportType.USB -> KnownDeviceTransportType.USB +private fun TrezorTransportType.toHwTransportType(): HwTransportType = when (this) { + TrezorTransportType.BLUETOOTH -> HwTransportType.BLUETOOTH + TrezorTransportType.USB -> HwTransportType.USB } -private fun KnownDeviceTransportType.toCoreTransportType(): TrezorTransportType = when (this) { - KnownDeviceTransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH - KnownDeviceTransportType.USB -> TrezorTransportType.USB +private fun HwTransportType.toCoreTransportType(): TrezorTransportType = when (this) { + HwTransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH + HwTransportType.USB -> TrezorTransportType.USB } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index f64de6492f..6c53356f5f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -37,6 +37,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath +import to.bitkit.models.toBalance import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService @@ -86,8 +87,8 @@ class WalletRepo @Inject constructor( } } repoScope.launch { - hwWalletRepo.totalHardwareSats.collect { hardwareSats -> - _balanceState.update { it.copy(totalHardwareSats = hardwareSats) } + hwWalletRepo.wallets.collect { wallets -> + _balanceState.update { state -> state.copy(hardwareWallets = wallets.map { it.toBalance() }) } } } } @@ -280,8 +281,7 @@ class WalletRepo @Inject constructor( suspend fun syncBalances() { deriveBalanceStateUseCase().onSuccess { balanceState -> runCatching { cacheStore.cacheBalance(balanceState) } - // Preserve the live hardware-wallet total; the use case only derives onchain + lightning. - _balanceState.update { balanceState.copy(totalHardwareSats = hwWalletRepo.totalHardwareSats.value) } + _balanceState.update { balanceState } }.onFailure { if (it !is CancellationException) { Logger.warn("Could not sync balances", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt new file mode 100644 index 0000000000..c968957639 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt @@ -0,0 +1,47 @@ +package to.bitkit.services + +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat + +/** + * Surfaces device link loss the per-connection callbacks miss: the phone's + * Bluetooth being switched off, or a USB device being unplugged. Transport-agnostic + * so any hardware-wallet transport (Trezor today, other vendors later) can plug its + * own handling into the same system events. + */ +class ConnectionStateReceiver( + private val onBluetoothOff: () -> Unit, + private val onUsbDetached: (path: String) -> Unit, +) : BroadcastReceiver() { + + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + BluetoothAdapter.ACTION_STATE_CHANGED -> { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.STATE_OFF || state == BluetoothAdapter.STATE_TURNING_OFF) { + onBluetoothOff() + } + } + + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + device?.deviceName?.let(onUsbDetached) + } + } + } + + fun register(context: Context) { + val filter = IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index b12dcc2b9e..1e1be110c1 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -29,7 +29,6 @@ import android.os.Handler import android.os.Looper import android.os.ParcelUuid import androidx.core.content.ContextCompat -import androidx.core.content.IntentCompat import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult @@ -161,34 +160,21 @@ class TrezorTransport @Inject constructor( } /** - * Surfaces link loss the per-connection GATT callback misses: the phone's - * Bluetooth being switched off, or a USB device being unplugged. Both feed the - * same [externalDisconnect] flow so the repo clears the connected device and the - * UI connection indicator reflects reality in real time. + * Feeds Bluetooth-off and USB-unplug events into the same [externalDisconnect] + * flow so the repo clears the connected device and the UI connection indicator + * reflects reality in real time. */ - private val connectionStateReceiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context, intent: Intent) { - when (intent.action) { - BluetoothAdapter.ACTION_STATE_CHANGED -> onBluetoothStateChanged(intent) - UsbManager.ACTION_USB_DEVICE_DETACHED -> onUsbDeviceDetached(intent) + private val connectionStateReceiver = ConnectionStateReceiver( + onBluetoothOff = { + bleConnections.keys.toList().forEach { path -> + bleConnections[path]?.isConnected = false + emitExternalDisconnect(path) } - } - } - - private fun onBluetoothStateChanged(intent: Intent) { - val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) - if (state != BluetoothAdapter.STATE_OFF && state != BluetoothAdapter.STATE_TURNING_OFF) return - bleConnections.keys.toList().forEach { path -> - bleConnections[path]?.isConnected = false - emitExternalDisconnect(path) - } - } - - private fun onUsbDeviceDetached(intent: Intent) { - val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) - val path = device?.deviceName ?: return - if (path in usbConnections.keys) emitExternalDisconnect(path) - } + }, + onUsbDetached = { path -> + if (path in usbConnections.keys) emitExternalDisconnect(path) + }, + ) private fun emitExternalDisconnect(path: String) { if (!userInitiatedCloseSet.remove(path)) { @@ -197,11 +183,7 @@ class TrezorTransport @Inject constructor( } init { - val filter = IntentFilter().apply { - addAction(BluetoothAdapter.ACTION_STATE_CHANGED) - addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) - } - ContextCompat.registerReceiver(context, connectionStateReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + connectionStateReceiver.register(context) } private val usbConnections = ConcurrentHashMap() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ccff2c6f8b..b6f81850f6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -177,7 +177,7 @@ import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet -import to.bitkit.ui.sheets.HardwareWalletConnectSheet +import to.bitkit.ui.sheets.ConnectSheet import to.bitkit.ui.sheets.ChangePinSheet import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet @@ -455,7 +455,7 @@ fun ContentView( Sheet.ChangePin -> ChangePinSheet(appViewModel) Sheet.DisablePin -> DisablePinSheet(appViewModel) is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) - is Sheet.HardwareWalletConnect -> HardwareWalletConnectSheet( + is Sheet.Connect -> ConnectSheet( sheet = sheet, onDismiss = { appViewModel.hideSheet() }, ) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 40bcd6e964..ce809315cf 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -34,7 +34,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute -import to.bitkit.ui.sheets.HwConnectRoute +import to.bitkit.ui.sheets.ConnectRoute import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.WidgetsRoute @@ -58,7 +58,7 @@ sealed interface Sheet { data object ChangePin : Sheet data object DisablePin : Sheet data class Backup(val route: BackupRoute = BackupRoute.ShowMnemonic) : Sheet - data class HardwareWalletConnect(val route: HwConnectRoute = HwConnectRoute.Intro) : Sheet + data class Connect(val route: ConnectRoute = ConnectRoute.Intro) : Sheet data class Widgets(val route: WidgetsRoute = WidgetsRoute.Gallery) : Sheet data object ActivityDateRangeSelector : Sheet data object ActivityTagSelector : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index 3eb472eb3c..b4cd7b878f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R +import to.bitkit.models.HwTransportType import to.bitkit.repositories.KnownDevice -import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer @@ -98,8 +98,8 @@ internal fun KnownDeviceCard( Icon( painter = painterResource( when (device.transportType) { - KnownDeviceTransportType.BLUETOOTH -> R.drawable.ic_broadcast - KnownDeviceTransportType.USB -> R.drawable.ic_git_branch + HwTransportType.BLUETOOTH -> R.drawable.ic_broadcast + HwTransportType.USB -> R.drawable.ic_git_branch } ), contentDescription = null, @@ -120,8 +120,8 @@ internal fun KnownDeviceCard( ) { Caption( text = when (device.transportType) { - KnownDeviceTransportType.BLUETOOTH -> "Bluetooth" - KnownDeviceTransportType.USB -> "USB" + HwTransportType.BLUETOOTH -> "Bluetooth" + HwTransportType.USB -> "USB" }, color = Colors.White50, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 2842e42005..1a9b39622d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -19,9 +19,9 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import to.bitkit.models.HwTransportType import to.bitkit.repositories.ConnectedTrezorDevice import to.bitkit.repositories.KnownDevice -import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.repositories.TrezorState import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -61,7 +61,7 @@ internal object TrezorPreviewData { id = "usb-1", name = "Trezor Safe 5", path = "/dev/usb/001", - transportType = KnownDeviceTransportType.USB, + transportType = HwTransportType.USB, label = "My Savings", model = "Safe 5", lastConnectedAt = 1_700_000_000_000L, @@ -71,7 +71,7 @@ internal object TrezorPreviewData { id = "ble-1", name = "Trezor Safe 7", path = "AA:BB:CC:DD:EE:FF", - transportType = KnownDeviceTransportType.BLUETOOTH, + transportType = HwTransportType.BLUETOOTH, label = "Daily Wallet", model = "Safe 7", lastConnectedAt = 1_700_000_000_000L, 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 41e0fbe97d..821e6b4efc 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 @@ -102,6 +102,7 @@ import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.dto.FeeCondition @@ -111,9 +112,12 @@ import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair import to.bitkit.env.Env +import to.bitkit.ext.rawId import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem +import to.bitkit.models.HwTransportType +import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion import to.bitkit.models.Toast @@ -121,10 +125,9 @@ import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.effectiveSize +import to.bitkit.models.toBalance import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel -import to.bitkit.repositories.HwWallet -import to.bitkit.repositories.KnownDeviceTransportType import to.bitkit.ui.LocalBalances import to.bitkit.ui.Routes import to.bitkit.ui.components.ActivityBanner @@ -286,7 +289,7 @@ fun HomeScreen( } Suggestion.HARDWARE -> { - appViewModel.showSheet(Sheet.HardwareWalletConnect()) + appViewModel.showSheet(Sheet.Connect()) } Suggestion.LIGHTNING -> { @@ -667,10 +670,16 @@ private fun WalletPage( } } + val hardwareIds = remember(homeUiState.hardwareWallets) { + homeUiState.hardwareWallets + .flatMap { wallet -> wallet.activities.map { it.rawId() } } + .toImmutableSet() + } ActivityListSimple( items = latestActivities, onAllActivityClick = onNavigateToAllActivity, onActivityItemClick = onNavigateToActivityItem, + hardwareIds = hardwareIds, ) FillHeight() @@ -772,8 +781,8 @@ private fun RowScope.HardwareWalletCell( Icon( painter = painterResource( id = when (wallet.transportType) { - KnownDeviceTransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected - KnownDeviceTransportType.USB -> R.drawable.ic_usb_connected + HwTransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected + HwTransportType.USB -> R.drawable.ic_usb_connected } ), contentDescription = null, @@ -1473,7 +1482,7 @@ private val previewHardwareWalletBt = HwWallet( id = "trezor-1", name = "Trezor Safe 5", model = "Safe 5", - transportType = KnownDeviceTransportType.BLUETOOTH, + transportType = HwTransportType.BLUETOOTH, isConnected = true, balanceSats = 10_562_411uL, activities = persistentListOf(), @@ -1482,7 +1491,7 @@ private val previewHardwareWalletUsb = HwWallet( id = "trezor-2", name = "Trezor Model T", model = "Model T", - transportType = KnownDeviceTransportType.USB, + transportType = HwTransportType.USB, isConnected = false, balanceSats = 2_735_180uL, activities = persistentListOf(), @@ -1491,7 +1500,7 @@ private val previewHardwareWalletThird = HwWallet( id = "trezor-3", name = "Trezor Safe 3", model = "Safe 3", - transportType = KnownDeviceTransportType.BLUETOOTH, + transportType = HwTransportType.BLUETOOTH, isConnected = true, balanceSats = 500_000uL, activities = persistentListOf(), @@ -1528,7 +1537,7 @@ private fun PreviewWithHardwareWallet() { ), drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), latestActivities = previewLatestActivities, - balances = previewBalances.copy(totalHardwareSats = 10_562_411uL), + balances = previewBalances.copy(hardwareWallets = listOf(previewHardwareWalletBt.toBalance())), ) TabBar() } @@ -1547,7 +1556,12 @@ private fun PreviewWithTwoHardwareWallets() { ), drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), latestActivities = previewLatestActivities, - balances = previewBalances.copy(totalHardwareSats = 13_297_591uL), + balances = previewBalances.copy( + hardwareWallets = listOf( + previewHardwareWalletBt.toBalance(), + previewHardwareWalletUsb.toBalance(), + ) + ), ) TabBar() } @@ -1570,7 +1584,13 @@ private fun PreviewWithThreeHardwareWallets() { ), drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), latestActivities = previewLatestActivities, - balances = previewBalances.copy(totalHardwareSats = 13_797_591uL), + balances = previewBalances.copy( + hardwareWallets = listOf( + previewHardwareWalletBt.toBalance(), + previewHardwareWalletUsb.toBalance(), + previewHardwareWalletThird.toBalance(), + ) + ), ) TabBar() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 2381e07e0b..0156048dbd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.data.dto.price.PriceDTO import to.bitkit.models.BannerItem +import to.bitkit.models.HwWallet import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition @@ -14,7 +15,6 @@ import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences import to.bitkit.models.widget.WeatherPreferences -import to.bitkit.repositories.HwWallet import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 6889590cd9..ba243b41d1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -123,7 +123,7 @@ class HomeViewModel @Inject constructor( } viewModelScope.launch { - hwWalletRepo.hardwareWallets.collect { wallets -> + hwWalletRepo.wallets.collect { wallets -> _uiState.update { it.copy(hardwareWallets = wallets) } } } @@ -320,7 +320,7 @@ class HomeViewModel @Inject constructor( settingsStore.data, transferRepo.activeTransfers, pubkyRepo.isAuthenticated, - hwWalletRepo.hardwareWallets, + hwWalletRepo.wallets, ) { balanceState, settings, transfers, profileAuthenticated, hardwareWallets -> val hasHardwareWallet = hardwareWallets.isNotEmpty() val baseSuggestions = when { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 8522ca32ef..1127c8c768 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -41,6 +41,7 @@ fun ActivityIcon( modifier: Modifier = Modifier, size: Dp = 32.dp, isCpfpChild: Boolean = false, + isHardware: Boolean = false, contact: PubkyProfile? = null, ) { val isLightning = activity is Activity.Lightning @@ -67,7 +68,7 @@ fun ActivityIcon( modifier = modifier ) isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) - else -> ActivityIconOnchain(activity, arrowIcon, size, modifier) + else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) } } @@ -76,12 +77,21 @@ private fun ActivityIconOnchain( activity: Activity, arrowIcon: Painter, size: Dp, + isHardware: Boolean, modifier: Modifier = Modifier, ) { val isTransfer = activity.isTransfer() val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED - val transferIconColor = if (isTransferFromSpending) Colors.Purple else Colors.Brand - val transferBackgroundColor = if (isTransferFromSpending) Colors.Purple16 else Colors.Brand16 + val (iconColor, backgroundColor) = when { + isHardware -> Colors.Blue to Colors.Blue16 + isTransferFromSpending -> Colors.Purple to Colors.Purple16 + else -> Colors.Brand to Colors.Brand16 + } + val tag = when { + isHardware -> "HardwareActivityIcon" + isTransfer -> "TransferIcon" + else -> "ActivityIcon" + } CircularIcon( icon = when { @@ -89,10 +99,10 @@ private fun ActivityIconOnchain( isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, - iconColor = if (isTransfer) transferIconColor else Colors.Brand, - backgroundColor = if (isTransfer) transferBackgroundColor else Colors.Brand16, + iconColor = iconColor, + backgroundColor = backgroundColor, size = size, - modifier = modifier.testTag(if (isTransfer) "TransferIcon" else "ActivityIcon"), + modifier = modifier.testTag(tag), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 74b38d8aa0..5af0ba3173 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -17,8 +17,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R +import to.bitkit.ext.rawId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -30,6 +33,7 @@ fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, onActivityItemClick: (String) -> Unit, + hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -47,6 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), + isHardware = item.rawId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 5fbfb9025f..a26a324811 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -68,6 +68,7 @@ fun ActivityRow( onClick: (String) -> Unit, testTag: String, title: String? = null, + isHardware: Boolean = false, contact: PubkyProfile? = null, ) { val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { @@ -115,7 +116,13 @@ fun ActivityRow( .padding(16.dp) .testTag(testTag) ) { - ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild, contact = resolvedContact) + ActivityIcon( + activity = item, + size = 40.dp, + isCpfpChild = isCpfpChild, + isHardware = isHardware, + contact = resolvedContact, + ) HorizontalSpacer(16.dp) Column( verticalArrangement = Arrangement.spacedBy(2.dp), diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt similarity index 83% rename from app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt rename to app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt index e2ab61e2d6..7ad0b79d3c 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareWalletConnectSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt @@ -26,8 +26,8 @@ import to.bitkit.ui.utils.composableWithDefaultTransitions * real steps land in the dedicated connect-flow subtask. */ @Composable -fun HardwareWalletConnectSheet( - sheet: Sheet.HardwareWalletConnect, +fun ConnectSheet( + sheet: Sheet.Connect, onDismiss: () -> Unit, ) { val navController = rememberNavController() @@ -36,21 +36,21 @@ fun HardwareWalletConnectSheet( modifier = Modifier .fillMaxWidth() .sheetHeight(SheetSize.MEDIUM) - .testTag("hardware_wallet_connect_sheet") + .testTag("connect_sheet") ) { NavHost( navController = navController, startDestination = sheet.route, ) { - composableWithDefaultTransitions { - HardwareWalletConnectIntro(onClose = onDismiss) + composableWithDefaultTransitions { + ConnectIntro(onClose = onDismiss) } } } } @Composable -private fun HardwareWalletConnectIntro(onClose: () -> Unit) { +private fun ConnectIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { SheetTopBar(titleText = "Connect Hardware", onBack = onClose) Column(modifier = Modifier.padding(horizontal = 16.dp)) { @@ -62,7 +62,7 @@ private fun HardwareWalletConnectIntro(onClose: () -> Unit) { } } -sealed interface HwConnectRoute { +sealed interface ConnectRoute { @Serializable - data object Intro : HwConnectRoute + data object Intro : ConnectRoute } diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt index 167db8a0dc..58a6d4917f 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt @@ -41,6 +41,7 @@ object Colors { val White64 = Color.White.copy(alpha = 0.64f) val White80 = Color.White.copy(alpha = 0.80f) + val Blue16 = Blue.copy(alpha = 0.16f) val Blue24 = Blue.copy(alpha = 0.24f) val Brand08 = Brand.copy(alpha = 0.08f) val Brand16 = Brand.copy(alpha = 0.16f) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 3822e73dd0..e68715ec31 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -14,6 +14,8 @@ import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BalanceState import to.bitkit.models.TransferType import to.bitkit.models.safe +import to.bitkit.models.toBalance +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger @@ -27,6 +29,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val lightningRepo: LightningRepo, private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, + private val hwWalletRepo: HwWalletRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -54,6 +57,7 @@ class DeriveBalanceStateUseCase @Inject constructor( maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = toSpendingAmount, + hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) val height = lightningRepo.lightningState.value.block()?.height diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 0de4fd0d7a..5aeebe9441 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -63,7 +63,7 @@ class ActivityListViewModel @Inject constructor( // newest first, capped at the same limit as the on-chain/lightning list. val latestActivities: StateFlow?> = combine( _latestActivities, - hwWalletRepo.hardwareActivities, + hwWalletRepo.activities, ) { localActivities, hardwareActivities -> if (localActivities == null && hardwareActivities.isEmpty()) { null diff --git a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt index 7060e98685..808b3b70a8 100644 --- a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt +++ b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt @@ -11,12 +11,23 @@ class BalanceStateTest { assertEquals(150uL, state.totalSats) } + @Test + fun `totalHardwareSats sums all hardware wallet balances`() { + val state = BalanceState( + hardwareWallets = listOf( + HwWalletBalance(id = "dev1", sats = 25uL), + HwWalletBalance(id = "dev2", sats = 75uL), + ), + ) + assertEquals(100uL, state.totalHardwareSats) + } + @Test fun `totalWithHardwareSats adds the hardware balance on top of the total`() { val state = BalanceState( totalOnchainSats = 100uL, totalLightningSats = 50uL, - totalHardwareSats = 25uL, + hardwareWallets = listOf(HwWalletBalance(id = "dev1", sats = 25uL)), ) assertEquals(175uL, state.totalWithHardwareSats) } @@ -29,7 +40,10 @@ class BalanceStateTest { @Test fun `totalWithHardwareSats saturates instead of overflowing`() { - val state = BalanceState(totalLightningSats = ULong.MAX_VALUE, totalHardwareSats = 10uL) + val state = BalanceState( + totalLightningSats = ULong.MAX_VALUE, + hardwareWallets = listOf(HwWalletBalance(id = "dev1", sats = 10uL)), + ) assertEquals(ULong.MAX_VALUE, state.totalWithHardwareSats) } } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index af849119aa..25922b9450 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -17,20 +17,27 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.HwWalletData +import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.TrezorData -import to.bitkit.data.TrezorStore +import to.bitkit.env.Env +import to.bitkit.models.HwTransportType +import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class HwWalletRepoTest : BaseUnitTest() { private val trezorRepo = mock() - private val trezorStore = mock() + private val hwWalletStore = mock() private val settingsStore = mock() + private val clock = Clock.System - private lateinit var storeData: MutableStateFlow + private lateinit var storeData: MutableStateFlow private lateinit var settingsData: MutableStateFlow private lateinit var trezorState: MutableStateFlow private lateinit var watcherEvents: MutableSharedFlow> @@ -39,7 +46,7 @@ class HwWalletRepoTest : BaseUnitTest() { id = "dev1", name = null, path = "ble:AA:BB", - transportType = KnownDeviceTransportType.BLUETOOTH, + transportType = HwTransportType.BLUETOOTH, label = "Trezor", model = "Safe 5", lastConnectedAt = 0L, @@ -47,27 +54,27 @@ class HwWalletRepoTest : BaseUnitTest() { @Before fun setUp() { - storeData = MutableStateFlow(TrezorData(knownDevices = listOf(device))) + storeData = MutableStateFlow(HwWalletData(knownDevices = listOf(device))) settingsData = MutableStateFlow(SettingsData()) trezorState = MutableStateFlow(TrezorState()) watcherEvents = MutableSharedFlow(extraBufferCapacity = 8) - whenever(trezorStore.data).thenReturn(storeData) + whenever(hwWalletStore.data).thenReturn(storeData) whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) } - private fun createRepo() = HwWalletRepo(trezorRepo, trezorStore, settingsStore, testDispatcher) + private fun createRepo() = HwWalletRepo(trezorRepo, hwWalletStore, settingsStore, clock, testDispatcher) @Test fun `lists a known device with zero balance before any watcher event`() = test { val sut = createRepo() - val wallet = sut.hardwareWallets.value.single() + val wallet = sut.wallets.value.single() assertEquals("dev1", wallet.id) assertEquals("Trezor", wallet.name) assertEquals(0uL, wallet.balanceSats) - assertEquals(0uL, sut.totalHardwareSats.value) + assertEquals(0uL, sut.totalSats.value) } @Test @@ -84,11 +91,11 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - val wallet = sut.hardwareWallets.value.single() + val wallet = sut.wallets.value.single() assertEquals(10_562_411uL, wallet.balanceSats) - assertEquals(10_562_411uL, sut.totalHardwareSats.value) + assertEquals(10_562_411uL, sut.totalSats.value) assertEquals(1, wallet.activities.size) - assertEquals(1, sut.hardwareActivities.value.size) + assertEquals(1, sut.activities.value.size) assertEquals(Activity.Onchain::class, wallet.activities.single()::class) } @@ -115,13 +122,13 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - assertEquals(150uL, sut.hardwareWallets.value.single().balanceSats) - assertEquals(150uL, sut.totalHardwareSats.value) + assertEquals(150uL, sut.wallets.value.single().balanceSats) + assertEquals(150uL, sut.totalSats.value) } @Test fun `starts watchers only for the address types the user monitors`() = test { - storeData.value = TrezorData( + storeData.value = HwWalletData( knownDevices = listOf( device.copy( xpubs = mapOf( @@ -142,6 +149,24 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull()) } + @Test + fun `starts watchers on the network configured in Env`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + createRepo() + + verify(trezorRepo).startWatcher( + watcherId = any(), + extendedKey = any(), + network = eq(Env.network.toCoreNetwork()), + gapLimit = any(), + accountType = anyOrNull(), + ) + } + private fun walletBalance(total: ULong) = WalletBalance( confirmed = total, immature = 0uL, diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 1c2782c3f1..15d518f6ac 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -23,8 +23,9 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.TrezorStore +import to.bitkit.data.HwWalletStore import to.bitkit.env.Env +import to.bitkit.models.HwTransportType import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler @@ -34,7 +35,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class TrezorRepoTest : BaseUnitTest() { companion object Fixtures { @@ -55,7 +59,7 @@ class TrezorRepoTest : BaseUnitTest() { private val trezorService = mock() private val trezorTransport = mock() private val trezorUiHandler = mock() - private val trezorStore = mock() + private val hwWalletStore = mock() private val prefs = mock() private val prefsEditor = mock() @@ -73,7 +77,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) whenever(context.filesDir).thenReturn(tempFolder.root) - whenever { trezorStore.loadKnownDevices() }.thenReturn(emptyList()) + whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) } private fun createSut(): TrezorRepo = TrezorRepo( @@ -81,7 +85,8 @@ class TrezorRepoTest : BaseUnitTest() { trezorService = trezorService, trezorTransport = trezorTransport, trezorUiHandler = trezorUiHandler, - trezorStore = trezorStore, + hwWalletStore = hwWalletStore, + clock = Clock.System, ioDispatcher = testDispatcher, ) @@ -122,7 +127,7 @@ class TrezorRepoTest : BaseUnitTest() { id = id, name = name, path = path, - transportType = KnownDeviceTransportType.USB, + transportType = HwTransportType.USB, label = label, model = model, lastConnectedAt = 123L, @@ -176,7 +181,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val known = mockDeviceInfo() val nearby = mockDeviceInfo(id = "device-456", path = "/dev/trezor1") - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(known, nearby)) sut = createSut() @@ -237,10 +242,10 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val captor = argumentCaptor>() - verify(trezorStore).saveKnownDevices(captor.capture()) + verify(hwWalletStore).saveKnownDevices(captor.capture()) val saved = captor.firstValue.single() assertEquals(DEVICE_ID, saved.id) - assertEquals(KnownDeviceTransportType.USB, saved.transportType) + assertEquals(HwTransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) } @@ -501,7 +506,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val device = mockDeviceInfo() val features = mockFeatures() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) whenever(trezorService.isConnected()).thenReturn(false) @@ -525,7 +530,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val device = mockDeviceInfo() val features = mockFeatures() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() @@ -582,7 +587,7 @@ class TrezorRepoTest : BaseUnitTest() { val device = mockDeviceInfo() val features = mockFeatures() val addressResponse = mock() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.isConnected()).thenReturn(false) whenever(trezorService.scan()).thenReturn(listOf(device)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) @@ -615,7 +620,7 @@ class TrezorRepoTest : BaseUnitTest() { val knownDevice = mockKnownDevice() val features = mockFeatures() val device = mockDeviceInfo() - whenever(trezorStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) whenever(trezorService.scan()).thenReturn(listOf(device)) sut = createSut() @@ -635,7 +640,7 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals("disconnect failed", sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) - verify(trezorStore).saveKnownDevices(emptyList()) + verify(hwWalletStore).saveKnownDevices(emptyList()) } // endregion diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c5ed5d1cc4..c442b3e8af 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.GetAddressResponse import com.synonym.bitkitcore.GetAddressesResponse +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -87,7 +88,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = ADDRESS))) whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(hwWalletRepo.totalHardwareSats).thenReturn(MutableStateFlow(0uL)) + whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index cb53f3900f..585eb8f0ff 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -1,5 +1,6 @@ package to.bitkit.usecases +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -19,6 +20,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.models.TransferType +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo @@ -31,6 +33,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() + private val hwWalletRepo: HwWalletRepo = mock() private lateinit var sut: DeriveBalanceStateUseCase @@ -40,6 +43,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL) wheneverBlocking { @@ -51,6 +55,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { lightningRepo = lightningRepo, transferRepo = transferRepo, settingsStore = settingsStore, + hwWalletRepo = hwWalletRepo, ) } From 69cfc72ee7c6e9aa1df3b6a1765a03df01a5e2a4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 00:24:10 +0200 Subject: [PATCH 08/37] fix: open hw wallet activity detail from home --- .../wallets/activity/ActivityDetailScreen.kt | 3 ++ .../viewmodels/ActivityDetailViewModel.kt | 35 ++++++++++++---- .../ActivityDetailViewModelTest.kt | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index c8b6223a58..a51179de39 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -246,6 +246,7 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, + isHardware = uiState.isHardwareActivity, showContactActions = isPaykitEnabled, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> @@ -328,6 +329,7 @@ private fun ActivityDetailContent( onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, + isHardware: Boolean = false, showContactActions: Boolean = true, boostTxDoesExist: ImmutableMap = persistentMapOf(), onCopy: (String) -> Unit, @@ -403,6 +405,7 @@ private fun ActivityDetailContent( activity = item, size = 48.dp, isCpfpChild = isCpfpChild, + isHardware = isHardware, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 02caf41a74..e351e98263 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -27,6 +27,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -38,6 +39,7 @@ class ActivityDetailViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, + private val hwWalletRepo: HwWalletRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() @@ -66,13 +68,7 @@ class ActivityDetailViewModel @Inject constructor( loadTags() observeActivityChanges(activityId) } else { - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) - ) - ) - } + loadHwWalletActivity(activityId) } } .onFailure { e -> @@ -91,11 +87,33 @@ class ActivityDetailViewModel @Inject constructor( fun clearActivityState() { observeJob?.cancel() observeJob = null - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) } + _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } activity = null _tags.update { persistentListOf() } } + /** + * Watch-only hardware-wallet activities live in [HwWalletRepo], not the activity + * database, so tags and change observation don't apply to them. + */ + private fun loadHwWalletActivity(activityId: String) { + val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } + if (hwActivity != null) { + activity = hwActivity + _uiState.update { + it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) + } + } else { + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } + } + } + private fun observeActivityChanges(activityId: String) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { @@ -245,5 +263,6 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, + val isHardwareActivity: Boolean = false, ) } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 9a33118537..87fb228927 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -20,6 +20,7 @@ import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -30,6 +31,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() + private val hwWalletRepo = mock() companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -42,6 +44,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.wallet__activity_error_load_failed)).thenReturn("Failed to load activity") whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) sut = ActivityDetailViewModel( context = context, @@ -49,9 +52,46 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, + hwWalletRepo = hwWalletRepo, ) } + @Test + fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { + val hwActivity = Activity.Onchain( + OnchainActivity.create( + id = ACTIVITY_ID, + txType = PaymentType.RECEIVED, + txId = ACTIVITY_ID, + value = 100_000uL, + fee = 0uL, + address = "", + timestamp = 1_700_000_000uL, + confirmed = true, + ) + ) + whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) + + sut.loadActivity(ACTIVITY_ID) + + val state = sut.uiState.value + val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success + assertEquals(hwActivity, loadState.activity) + assertTrue(state.isHardwareActivity) + } + + @Test + fun `loadActivity reports not found when missing from database and hardware wallets`() = test { + whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + + sut.loadActivity(ACTIVITY_ID) + + val state = sut.uiState.value + assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) + assertFalse(state.isHardwareActivity) + } + @Test fun `findOrderForTransfer returns null when both channelId and txId are null`() = test { val result = sut.findOrderForTransfer(null, null) From 44044dc987662e97a4a8b90607382db6368ecf47 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 00:58:27 +0200 Subject: [PATCH 09/37] fix: hi-res trezor suggestion card image --- .../res/drawable-nodpi/trezor_device.webp | Bin 1022 -> 129882 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/src/main/res/drawable-nodpi/trezor_device.webp b/app/src/main/res/drawable-nodpi/trezor_device.webp index 349867cef203142703f24824f67f877596a0e83a..e64e3936fed13e986970cbcd91dc4be7ddfbc59d 100644 GIT binary patch literal 129882 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�`1g4` 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ªQePc6050^zpS3u+rC&JmWt zy2E95pWy!eTvwk`Z>)nHYN-PUJAr<~%!vpeKolm~|p*tqxNS+N81Gc&n4QX3S9uH}kixJ6iQp@z zq0KJ$121MTYIh5uHvxzAdGW$mRO4Mi#b7Wv%AKXpbkBLR_lV~0tu>|JJ$+}7`nU}M zOUh3pj20D>-I&yFj+~rIds7?i*IWJyV+6pq+DEkJ##B$sh#dszN8Tfc90c)v(jQVU z)vS9g+&zJSDMBcVO7keV))48g-)YF`0}!s1Xth(5AIFzuUs1MLF%5MOO7341ZQ(EC zsR`7=vl3P^r$${fUfGo634utoiX`cM@KW3s3C=(=9$^lMPw2o3niS`8t+H8Q2|Ox3 zQV>?8Evr0FAY-3a`A(96*dRRXYbNt#Xu<0ukX;&)7~hp%cpac1c&uo!gEm3=AH0T~ zpg58)7?;y4VYrn7TIrElO3I_;?7mE`io8soOw=ACyBL$f8o7+_v47LkoAce5^f<5 znkM2XOQRUL_20ye9zeLedo_YBF;)2nU2DYBs`L^#6^<2z^tz!Ve5!PbnFq=ORsVFA z3L8eJqt+-|8v=iE9CY<54}SQw(#cvc^q5S7QKx9fv(s-A@u~;U+BK1lY}nTADST#} zvLAhVc(^4>Xm(F=!uXQB+fHq_*y!B8Vv*V5zmOC;ZQ*w39<;1eqN#p?n|33!c@~Ej z`!HKOPNIeo?B1@ zCdgJ-C>hYmxYPUkJzF~~e*S325H`+nBL64{NC&4Q-1I==Q90}_8Y8^s1l2puTHB5> z<14ZZ1YaS^uvQnpABw@&TIZAI0%cSancHqqVpXyNnwg)p!`EZaFFuQmVY zCn!xA#$cdjt0){{1naZB%v38-1{8pL(`fvOUpyO@q(qLjfz%lDSt#4|zO7^S#};prB@Fu&nZtcprPP628$1o-1ysl#0QSxI0)3t- zi$qG57U2YTPmFN7Sw4)smslFJ9j$|5(58q34rR8vV(D=X{lV{Eo=k_|krw)JskH$j zYf0zrW(!-grbRRIDTFK!U=f+wnr@C)I1LRTbR1K@9?f;S07Dj?i^Hxf{_d>9*lC#q zuK;E4Qn?85aEu_+YY`&~V<#^mFK6bGduZCxrE}`s@PdR&_fdW`0s3O6=ZQ~s&k(MB zLn36{>_c1T#o1~nFLlK|37PBZ81Lj4H~#}(DW z_6iF!5vdoqymAVzA=pD^sybqWTQO-j#^^%Y`oXUYt!J$-b@2!j>x1}(L2|N!6Inr! zt6w?*d=1z0``;-mLkMzvnF&|Th|Zy&uk(_hw$R;<+zGl33*c~0%j3Fw>UqvH66!o> zX#3+PJc%maW;-h&cm;^gm6yA>zOcpt173r&3=DhmBbc&JB_Ft!dpVd3Qc65E?d}+9 z;5D{eFpV5zPk+nW@kZs&Fch}rb6szPF-;!=K2JlV0t2%D)`JM(_i_qIO^a}q&z;NK zOG+yZf21C{SuE6D>@q-R?H9%rn^I3=;VIky(LqHzt1{cB-6Irn)23^H1d5ETGqj0* zk;AT&RBr{MAX_-s!xi~VX*+KWbmXSHgHSkoP zZ?6;$mP{v$xAk+9M#@o6IgL}M6%pA`x?4YK6l!8)!%o6a(#}&5Cajg8g9#`cb+jYE zVFxI2W9Urdnl)|r%Xthw*=rxJo^@G3Li4G{$BT!w-jF|fh-#9MyT{qSz$7%I zfmYd#q9a{r>z?27s3dOZGrG7dM-%xT@7#rV6EeX$;%2eJdJWVTayGC!g8_euS3i@c zeyWecl)85~USA)8f3>hkc0utM3`d_PavsHrwnnfd8>8J&rPL7t{_(c;+`=BllN?G#{wXZxs zubmnb^-Z1X*9m?NMICWQ(Dtbs7yCvGSkOc|89I<% z7fm0CtJMDNERTX;&_`K|x6h$EH_`1t#veHTvi?)X;Yp1;L8|0Qw@c~nWSynO-8N_# z$?7A0zC1y6O3VRrJu(>YDDS$vbF#sOjY~9ZJXgT^n~rt&N)#Uy@gc_&tc@h&ry zA=2{0FT<_(>IV47t9Tu|)wvm6Xb7!v2EqZc%Qqn|<8U%Oqx`7(teyUoz$1`>e{Vh62iiC~|ESYfaBS;r>EMP89P8J<$G2`5jjey5s6) zr7`{FyDToVmj=;&R3y?~t6W>mgt?eF#Cz$_Q^d3`ze(eHTUioNn|4_T62o(yyaXxm zk(1(IqlXm zgfq~=VRfEL(`J-8)`vkaTxp|oJeAtY1JO2|7{5Y1DPUGijHw*!x3b_g9_z&0vbofX)Nw ztMzAX%WPJINiw<(|D#A{uU9JlaOu?+Srf`z8YSw6-bwUR`MVyN->1xuE|ntYU_ni= z@#>SkQYf?6J1|047E&{%x>F#L5$5H)a^sZ7nQuMHlMW)7R%FOmeBT*&{2v-woDOzT zMOtIj6Zs-fZJ33)>(TP55y-SgIWsf}aNhJEpE;93y{ZwGMnD*_H9ZNH^6X<#v7~kA z@j_|j^BYukr>rBaB`9}JvS3Sy!A~l6vZFgo_>1h3Q3A2`g4W%=M^N`@hJPeK*#mI# zxGd6cE$=5P_C2xn;)`||BEJg;wC%MSs&KP|Kg4$c#Z7W) zzmQztautD`dnJ+8E7tHE^#C0wf)*P^CL->D)f}*}do!hJA=Drut||(c9^<_2Bk-<& zybvHoJ^)|3JcuYZCmt_Ujzc{mjQK8^Z3QU0cY!CB2s`x8Y2J5AUTTR!Wb@^C6KOOj)`r- zWKg9(iXvwXq)Dk)Q6&RqJWvj`h*H(PgFwEtDvd9+_BWyL+el`rfHlRW4Wbd}XeQsV z`H!N6@8TJr>g)$r2mMR&FMF3LFebMg>7m5c{oJgIkyMnq5cuw4pZ5EXB>ZCWM&!F#ZIwHI46Nvr9C;ZxfeS4~sQgd#tegITcA;j)*C}Cg`-#7d$OSmG5UrxbQ zY0QKr$}rzN5~z-vJr7Ed;WG@{At}fERAq|BAXcBvx*xfz@t1@uzeBjidEz-#14w(n!@Tyyc^#?z+PAtNYUwb>_{f$sT(&jl_QDJ@|>v z-4}B}@3s&pUAAxx4O=c5c#J@9WtGLrQ=OQaDz*5hj!xqdpOpXbiJ{H#E zR!MI*7CbcTCz=KwuSSejCm0{~UQc@MzgIT?q>qmJQK|Inz+QJyrrpM`Nx$iv9rKiP z2iPnknqVMPB*Cw^#f-MGsRy8r%*-RO7p%psQ6t*!VQMg|HSfG z<3R{nx(g%#$q9NSxuJ)$q6Mb0N-^F2AfL}eq47!SU(Q9fKVCIstB=m3keV^?vo@yV z0Y6XFh?8R|@71|hn9?%q^hZwCUWjf*RX3u70;U4Uz~sceqn@jds;O*gsA>`oo`=^C ztK2omw(C&NeQ9XlXrw^^SNFGWGkGSAVSY=GWcaH4*)wc9YQ;x&TL|B#s?N#JXZ6E- zWLGXFjJECzIVE?axwBBd1IwYFgb87e|#cduMG9P$< zi!}bV>Kdrh3Ni#5dYuK@T;kvR{-qyYQ-h&icY)vyaBloPPHM>!Ur@ zC2H^L^)_}M@so3c?ljKu#VEW#Q)y+M0rzUKHiJfU)Oy-Ou!Tr7`&*D2PbifW=|_^Q zCQpH?r-U;IVSAN!XG__(JRu4|UaR;R*}51L-)HL^3F6>y(h%TIi^xr5RGScWMowfxHd(9NLDfz~ zJwE$Uhv-l}6V)8EN;v-fMHL($l1fS;j#;Vd=o4=qyu=j+r7pg*GxUJhltZGxPH=Ev zLj@9lp~K4zS7~wzM30-dJ^h3*(_oPR@q^4W^dY44|5VCFEmn&Hm<13~Lsn8EB2v$O zv`YJmTQ67YsMiHALVm^`C|MOC=gM;QX!KEE>T`h(ml-#jr>YsamC<$3n@?%L>~31@ zQH;=T&WAF?be4(3YmxCrs`RNwv7$>Q(n@^Q`v3Njvp5q^+uKfW$E_$JMhl_`PfH*G zjsA;8l5BalXoJYcnRYy%lUe*BZlb4s)p{f~euI@Q_o~hfQNgbghye|D%2vMCHV0l+ zxS%e59tZrSt~ll1hz|tw*RWh>1mC-#2+tZm2GdIh0KK*NK{Of5# zJ$~AUdl?Isqma%D7@5vk!{QAgY>b2A3+<}x%4iIQqQtEiA50*SrH;eUG<$9F335bK?&U%XxszgS^7ebH6&8&H|Q zAsbJ9nioUP|MyBXIzPDC|I^`un6^{{lpY(Ts_p2mButG;EZUssn|OXZA9F|ao}8gl z4b=o=5(lkd?N}rRRve=|ihPU*9ksDkyl`+3A4qVQ#xR0e40?24`*H$X^-NrDoMd_1 zl}7@O3@BqXughPOXhV=(qCp&3QUeavw%dR90T(72>AQxgkWp>+r+(=oBHdLuXZ4}H zJ;pH)k`oWG*}BWuFLdvQ_?2%devF!NmhhMkoFY=|)$*@&n=E2iaHQ7DvBVlv^s{IL zqiQ;BQnxcLfqR`5uLJwzOWXvknTsRvX&y%aI`5w5a;(Bm4ckLVw)xDiPpC3v0R~i>x%z-NOsVjI*lC0?M0?H3!MNC-;Y8mMA8l&iSPpn>&qNo7pFM)o zp|Rp?hKmL5i4Hyv2eYJCGk70~Tb#T-5x}5^7AND-_^*sLQ<2Fie@2N}`*V2S(Z*G|A_IzD-Z(ez}WZ z=r5uGDS})g(;<-D@C*TY~jO~=5ncbW%-=so1W;B zC?meG00%GaVcTcLXWxUJn|zFgUfwlm!aAb?m4`egp-}f+EG89CC>i)i_a@Yh7CXj;k3itz_LE{ zKK8KVTEC3h*JVUFybtUd^PJI{2ng1`=by&#h|&=@ zA!de8j<4Ecu@f&&S?^~~vtAORQ?i~LJ-@tJJ0ii^p(-A^T&%5!2pB*^z4!Sd9n!qp zPzb%aKO7!T676eF??G&H1LLe$xC$nCz@dfy7b1xyI)p zw%LCjM%{+PIm`_AI`iwI3D;Fxf3eMr(3@j2i)Y=8KQHak35*9xTP!+%w$B4%zJ3q| z^gl=<74dBwMtwd=r8g8a&hscNe)w%ce$cNqK*vw@F&v?X%@(mCbP833b7AbfceQ=~ z357q^C3}DqST9@<@ER83zYW^okJX_;Mr(w*8It2AUK_DjK^E0FsDmjz=Hch(8Th#^ zf1DXqkHc^FE6@Uo%{+Z8H>8I%!=!tQu*cF8Hwddt4FIHqZ+zSSe#n{6)$4Ix_s}sI zQ|I(f3%bUU;E{_BOCH%9xd^3shlXKa-3` zNwmPE#^;bsyiKzeYzYAkr`d=He@ zV!=3a|5nrvX1yW3`FQTq+D1biEN%bSvcJ0t+y7wsm?K;4?c66>gyU5*!iEc@H~9xc zP#WLHgy#jEt#oyJyBAcgsZq3YR>fFS{OS!S{MZb-Un@ z?3wFrH30W41^&Oi!^*$*8MCHD$L(dkVzAmg6Ega^U=dr%VcCu8{yZpWK%atk>*1*| z;RBpp)58P_^)eFsRa;D2)^6FYq>IWnC6ml3c4Y;kZPADZ(F6TL3U2ly_@Cd1UL&9M z`$wTzx*3c>`A;0&l0^{A>lX0yY97L4gyWE0(($+eIcjP-uoM-Ju8X(+$GbwL;n5*x zj3jc3YU&V1!QsklV@OpgR%k8Ni6WU7$ncYW$BM&OVFbk*!SVGH2jnHXVx zEN~eS9%Fc%x4K^ix*5^%w0TfgjikWzGInHc^!7>H{$v5&Ri&`0u2U1sfbr?jwWa+a z$r>aF@A$z?jFq2yT;T=3exq=A(3qyfYCc=-m+OZ}czVP+@}^H7eW~?q4DTdk(@9H{ z=I-XQo@LYj_^(R6|Ho@NNitpw|Me*txMOa;s0sQ91(C7Tz3xGcVu_*wxFVX3_YLni zS-NO_jY>V35jVJ*uofByq5(!xJ_j^Ic+DO6jkrdu>DzKQ?x@3zis5jm*^LSZ)qufR zDBd)SF7&^go0%{00W8adG z-+5Xx4))$NyCG2Fd_2vUtYiAmCyE2q}A(82n#a z4Vf!Lk7C(UA`@3qSluNKF}Teh*;UnZR0Xsh8G8Sd3)d@P0Xy(1F;)uQ<-XOTol5`UEBt0V-q!&suoB2bT$GuJYg|*M|z=-)h7$!Rjz(9oE9#PaH ztOR=}!*3BaO351iFRr^)Wzu<@QDU~N`hW2Qkz5f1DgcAB*E~oIYh3VSW260sV&4#j zTj3e=+MEAbb90UUj}s`4oyn(H^xCBstarw6ME;9P84UL^UjEWfJO$dID$8UG9DTXJ zFFcysc2_TxiSN0=2z>xAlpba#@qcP?PmQ3?goAJ#y^|28a)lj{DRis?LcUTx6q$ZK zgpDmCGfIM_()!tIavz)hH+2mlkNeNvwUItNfCsXn25X3Rx-@1>yf!5&G*HlT90|>d zYJlJ{YY@b9wD=W6Kz&Vm^+#&uK$@ajRE|o|05b<1>|U`pyfKMMzmjvw~xy*^!5RX!LkzYJmNX z@PzZd6y$ztgRia(piD1&oD(SeiaCXf-n4SmU?lhp!ogV{<*s&)lizX@QUYZQ(fLx;E3Tq_J(&#w$E zY@Le%Ts`x%{HQ-R>J`GvM4?d&)=nz+*s0kw!8SxL>6rnfF_ATV<_GcwR=|igSxlY2 zhsY_7_;R~?BvI{u_*Z%2S4qL)-2 z4(d7ouUC5Pg9598;gz2N&lmE30%_bfR^7}ks{mImc`SwCDRM^py$&rzu_6tV#a^+1 z+ZotpME)BPz!)@Ubt`KmDUM3XaYC~heg`lrVpxQ=$0Lkw9*rMn;`zM)L1=T;Dr}Q; zg>oD&F1vCwFbq-;LlUnFWO2hm+Z}PpIoxd%&C6Z_PDfA3D%;LV!G;>SMWG6cm|p0K zsu~%<3W&i15Q@qNfJg0NY&Y)R2>mVCr zW%l_;m9vD5OXnyUl<;Dx1fM$UWA~v9uW(oG(_#VlT}lw5{#E7q#VMr<9AwYeE%@}% z_!{Slg(Q>E#G4HK6YeZwQw$;FF#+%l@mi1LaWYZ@pd7=}bNnv~UNgHbrwq4`L_;?vmY{ zDAp2F)Z`6@M_h;lOmDst;)!R_Te#qK{$(!oE)Tb~2=OqJqx88-Ru&tDyYk+HGw$-4%(YAhQrWS8uUOBH4WKf zR{=Y;14wjo=`nvzA|s8DNGACt$=!fei*#XwuQ)gZzvU(!E?`Ef7Mg-IPi4IWGK2|8i=cnbgqJ;b=|f**R#A&q-diAw8c>(O?bevn@IY` zzzd#ChBmk^Q7z>)xmQ9HF`ivkGA+`pNZ5|ET8w~WPbz<{YK~}jeSd2?t-RQL+d(0* z%U9g?Mj@Lvn#uvWp%hXM`i@%$_mArfnpT|UrVZJUvFTd$B??}pC>CtVwHJj_l?PEP z&ORn9lK`<$J}oFcv%Nzl;ENxFGjX(CIVi5JvB3Y?NXRhMc5iq4_(Z^Wztw}df%?5G zkou0SR3+G_;1*wXjlq__63bizz8d5sBx2f3aMYXqsg%Q$$)Op?cHbwLU4O(1(#Iqt z34O_y;&rSo)BdBpKrjya+DFGzD$X`_uOfW@+m;?{T?TT;&pxG(!RG^>b4R`6=A<={ z!JvHmaJ&?|V^k#e>+JsOLD3zU>%c4($VpLuhva8~G)OIG=qrOUbQ;;= zO&)*R4CvRN*YUH77NOjKHG8LZbWs=Z#Y== zKj;9+gx5RfFoK##lJf%!Mjb2@Aph!wFu2s5tvJadVrfggJoAnJQ!nEd$V9<+xY3AE zyR(%d`>ZEtj=zL~bK=yxIi3H8s8V=XVrK# zgyYhDmRE&(v+PHwQz29emC=StlnDr*DS9!@3?SX79@dUZ2lT-2wKH2+nMc&kf<2cW zT!SCh)|F3|Jg4S-0EO0JI=VCignxWcA;Ymt8w;VVje`vdU^oS2kV~M-!t7PVBy?V! zLD?Ic@9HK=ys%tQhcQoYCitmxtr#aN4TpyWMQU?y|IWh`5JZ|LRzXG>3Q+5_lX6Mr zTXWic3cULLy58ZjKv)YfR>szWxvw{#H9uiw31DnfWh-B4(3`2bE0mw#=o=~bEe2=a4Swoq0CfSfTlSaO5p?p?P`uEUq&?Ls|* zTADKUeWg(d9*qDaBeYFPf{eRdF_iXJ0kWtaYELF3c(rl$w=|WCA{AiwwDV>k1+UAh zTsp^t_Qe}Q_}%>k=r0caI?IwRu#sq0ZbGgm4HoldI)s$B%VH<>N=*>X z>n0N}H1pN7o+-d)5840dm0^VWww_g;ND_r*<`NtBiL5T&zWGaba%)7b^;>YRNo`uL z)Iss?-~TF#KMZyl7H&wGcihz2k}d%OiG$%1cw7 zr{#MB&t7z0?@U5i3>Rt}&loG>Tt}sfgnSIUMXM5+1-YA%~Hnkm-ypMYtAFRd0qgx7T>{$sP|0kRC2B=cdx_1VY(!w z-fyEX-cyoClrnqIpf++kr_4qF`^Sc`$(4E`VmPY6Y(5k!vLYFMK-=)ahx<|nII70 zKllIX|8W)oJW)hy_9|a>$EmL@goOf^P-Uwj-sO0rJpUA}H4tSu(i6=s&KFQ0THX)x zpt9kk;`QA0SmBThiW?c$+$;sQ6mC$;`9IQ`?RvU@Qw?ZXPMlAkQ7q&=T-YtBm|R1$ z7BBE8_@(pzYp-ajLn3(_4)KePcOja0h?}+t$K8@V8^S8=xT5ZXRj{{(43i}5F?cpf zl=onzb0TLXr6QR0p(s?{N1$!!^@Af%D4?PV+04v_S^L|mZH62B7m#N?jL#k-)%U)i zZ9nLO;%#4uV{Gy#Eq_er`%zzi8O$AExY8RzbU4%4;es|j=N^G67W7>rx3cVbb@C@i#C_0?qgfZ4bX8(}>yR5(y6b&QLgh-m}nQC^Z+3w385 z|28QJ+6x?3_q$^dLy*Z{kV>*^SF}%Fm@Ctp;;IQX^+ov!)dDOS`7w|6>d0#D1d9G` zvm{D-O)1?mAer-*z~forKG5Tm!XH|ZfuTA|I3*LuZ!yl1qIljNWyhN#!Ry(1D^hbH zq(K%f^Lvl>z#P@2o#noQ7#V`48NFl-b=TV-MgB94L8eKOQmt+L{z2GvjFkMg^Jd#v zEe1X0^-3Hdu%v-^9@Zy~+i!P7Z}L&IR^g%RUD} zTD(_LG4t4e5H0gC$5+HVGbk<}E)XHvmKx)U&cjcuX5KGtkiSKL@RJ(Qgpc&ZroYlF zMj$6VCm@h6v@{4TNws0MpIdwYv0ZwU?k`M@iviVSouCIXjjc^T-g9(Rhv8eR1l+HgnWF;%^R<)#*+)_N<{+kMQ8m>-n@v^5whllof5w3VAylD+ch8eM zN(eIX&Xe6>kN*@esqzFFB2R^U)8Nh$vr;iJ;2?HF4jBUzcuHy6Poo!rx%o|V^HSb` z@1pGB^%?W#*M!@J(}1-nr4kbTP~sJc=JKYa%9(|uYDi4u=lL|mOnb2Xsy7R8BQ|Ag z&@O<JT# z&F7n>5;eZQ7zk4Gycn{OZuu}pA3JGVrzJw!vc6MedEc=W=Z++%B#2pel7&GmX)bzh zK~n&SX@rRwY%lG0^Bl$CTsckNTSnjSK#t9k2-lT(YauolE<~-on~c!>uZx+JsEy9_ zk_sVvFz-(ULtg{AomRKi;5Ugse_H$0Hi)bCRXBB-fw?t2VH%=l&`;Hes3WI%uE=WT z$GmA+H$iev1XS5QXp7Q;AmSnPPDx;AbrDNVVP~b9rniaS*o{W}r0Zt8;lhO-2`R-ygpvVgVdWDkq;k! zCS1A@>E=ieK9y454W+R_84rhqxH4>^aGTr_O)p3=a%6b2op*V`QyzzK9qUEjn%tpb z^cmD)p%pC$Nt`5lH0GQNphj^U4WH?OzOv=e8w`+yr4e7wYk$NG8g6Mgj_&G zX4ixaDRi`_KyGfib@&UBcyU0`FQ?@y3V*psSVE7c2EB{l!Z063%S3Cg%?c5Rc#{v) ze~^N#(}7%o4^PV5b|Dla69Ft#6O}%>=Dq$5_ht@Fir1|+NRoA@X{7lI@WVwYU9lfg zGT%ZBkvhSjx%29kL3)I;MzKD{+`no6AN~2BoF+zhv+GU#V4)cuVV@r*Lm>06)IVkj zVAu2#QLt@!gbrVZfNcwuEc5DihBtb5{>icf_cCrJt>$aJu$pEAPWoq+B5bls40AR1 zxhN>dfe8p*PBvO(46zF(kuCjlC<@h451sX}o=svO$yXIs6~{JD&AlnN$>X&?c_P>@vCI?8bz zN5x-ilrgR6>@xBO1tc=aigOy%57KiSHkpu+n%E1BeXRC}a)O*Yo@`f>_Y?{Vr2fZe zm(!Rh+nQcUKp^S1s7Dc6hdmjMhyEEyG+N_|Fa6cYu4R_~s&)zyknR*-JC!hoN!rZD zbHp`58n&g=5$9-f<~D(`nC63Q&2Y{P4+69p6BNE)^G$ANI#w|;Mt*1HV@Zod4+a;G zo2K<;TIh+lC_Yf}L1yrDtnoPsZ_{6% zow#0zJ$Rx|?IVHD1YPi;*xX~2|IpM;sTv%u?Q|y-rCDEyJ#*)(O(Zg*av3G`3r}7^ z5;z_@;TgN}`Nv^?SLSQqI5;|{25qNwz28%QmZX`7@=0%)7?b#Ej^fLJ^|fqd6ODYj zX-~^Z#WSOVReQ3TE@B}owkJ(@bbadLe5Ib8fg)03tD~T?QkX|&GZ@z;fJ~nhvQ`+3 zVthFYZeC2l_ajEH&3ofL-%VC-P^ea+S}QdvRr^ll7#7B^5kesV)P7_2=V*#R;-i-y zo?I9{Lq-s1e~g0oqe=}PV8*y;^_2nzNBOhfMDCd z=z&p+_BqL;g1Q!_WE0@d>J+Z9w|hd98bL{Z3$6{HU75eQ?b`jV)7{H(S2fztrbVeb zufN9y&NWUFkNzjb`f^d@fWhKViP+1r=WSGy2q4f796LSYQ9UR7$3Rg`Wyh;JrjALlrurYXoCH~$7Y0L_)Gqp#B)KD)j+yqL7%EfU} zT=bwON0+K0=1i}TQ+dyRbG=4Dxc@xrwt_*s{Sys;Lov&fV9fe#>7Ed!bB|s?yZth} zEb0^6J)f3L3f(R)sgkki{y<4dAhw+$LW9bOqiyVczU}g3$#$BiTK^U*ki|z%FTArkv2tP5^dg*u9yltZ^a`^5`6YlBy7KL{0_aI+@7_@$vi{QzZBIm<}5Rq6RxMj9rzu0Ju*Q`V}~k za6xZ_Nz}L~!#^9m5Ux|?A+xon}Zf-B(-w2-whuJr4Li#9O#n_pO^W8z1Ib(?U zTQ6WqUc7+KZ6wym(qLrI$N0sk(Wz#0_k>Xt6`i9IfJh5^Hs#kq&LDy+6efy7ZaAHjfQq-;%wQP#crrW_j(ecG< zIjUKJIXssrjqbVtCe!&y>gI`bi!zFO&;!Q@UYoyzZG2R`^o-Wo6nA8M=J9QsZU0h* zi)rBdb$#Yp4r+itd*_QP5Rl~boqv~k5{%wLHro&5*8-bnS1_h++%da?-xd^x#+oKU zw^^M1Wo7lKJ!0>CKp-NqfTTozFFX9?u1+Cb;gxaME9TJdz9J@-y?bbLs85Rp_rVm| zPCtFFA+w)(tYTrds^P#1sdw{0BAT#;qHfNvU~^Ey_OBe8TYLM>ftJ&QqKwd8OA!=# z{77_Il}Q-pKc?0+S2dRfRSLuy(qW)PMR{!^D;ScJzUE=4&(8f7tTi<~7_Nnj6z9^J zkSptG%WSFle1T%OS8^?p(Cx-(j^=Bx&x`~YA#BmRiLraKnj1(oH3pxKQMX=J=_2ZW zT?GERJg&gx(ea-`I0@OP6R!k5w`O6Ua?H9YWsS)e*fJ9Fbi(eMdXgS9C2_%w_#f7$ z(%~-(krkuANxriU^{OF0L)N~(Op4*u6W}}m++hBfUgwj>iAA)VOyp*b zF%9yMCuCj@x*!}4DSH! z8CYBZBCwN2$wS^do@n*L5u?LcldDB0%eoQ{36zCEM2OI!;v8SZbJTVQYMz9IHOzRa|6v~Bj}rve-Dzxo!akp9&UE0(3m zQ23{XNZ3$9bCppqa%lnM_k}PAhx5g?(*!tF$a#5oJ984_XKkplAj|GLkfnn4xT0i| z){vgO!ZFv;YqednR|ft6`qunTR8%d89-7c_-gP{^sTQxFMFEt!$pQZYXNwZ_*1C|# z1gtx%=PQZyV~e@HIjAIGNB}i`lMqU~3P;OeZ`O=YDhlV z>@;;1h2-V*ZdgxQ_Goa6Es7a|YDWFKQZCiU5oC-1^Zkoc#!oGV?)=>l{Qqp-(wYr- zvrDF5A(Jj^GE-oC)%|^_@w=+|*LqI&rfNCBPXTsK;@Z71;V70L{kHUojuJ6?!}8kw zrwCHlHmNb3w}kEz#*jM;iDYCLfsKwD00<6}W8eXu*m&nU9?L=)A_uMm#=?R!Dx75Y z(Q)ZOGXaFS0Kh40DkTD{*~6xBX7P)Qpv~jK$}iET21H&8 zRD)4&f@Yey*uac51fTrMD5iOXij znvXcd-pktT&H?Q4(a8Z*n-Q~j8SXiqX5R+hEONfrjXCZL%{I}Qz!lsANr;mjhdW*& zLn7($;aUnzIEGfy8eND(B`EYc90guZv5QhU$4pG63N$o9!R`D9q#ytQ000000JsfO An*aa+ literal 1022 zcmV00E$D+qP-j zn%A~%+qSYB*+!;_5kJ7sFMJUr+wVoTZCkAcYp%7&I^}*qL`*=sUUq2Pty5DOvF7yE zr@#E}@Be>)_d>hcI&Zr8(Vwb_>f8P6nR@9rltTFpb@aXYFBKd7wO?GH&&0}PJQCLT z9jl{Kcs=Y!gZX!Q&``5fKt=UT&@>e*7m9olG~sW{$*3U0nwa!@8D;?ii zz8ZcAS{H>nAg1MaHEf7UcLpi$4qDq7Io%V~?CXZ|;D(@dEdbzu7!$SfzXr$(|8|Q? zFI6F{B2T379XSLPi@@!1t)Gyrf+_;+iA$4y0`nk(=;9jEa*B$8Sp24W9cj^rWEHW< zBdOj|uYg#2drfKJHO!N_H`RaqZz&Z2xT$9oNV7f#M1Q$FG&Rjm7k_~t@7UZSHQiQI z`ZP<|K~_*WAS40+0FVp-odGH^05AYPF%pPEq9Gv{90p_{0|c~y3FtmhC4lUL#@s|{ zBbrZ*%|d@yJuUif+TL7{3|~?(FSwt?!Efi4mz1pfWyh36YNSX;oo=F zLlo&uw1Y*)J3F!EC2xh|JbshfTGRu zOyTe_JRUpdRO1{qe#vTu{N&s%A!rfQ|K~7t_7gC{dm?|=9^c!`I@6@Lr*M$Ci!P(lV_s*LBM??fslx zvlD&4?ts#nY57-Xob*v^eyLd_#OJd#<;N}io%zB>Rucupc9Dkftr)y^GUe0-9j>PjeN4YeisY~W04sg;&Hw-a From 2c3157a936083623c1cd85d964c453e85d16c6a4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 01:06:37 +0200 Subject: [PATCH 10/37] fix: sync suggestion cards with v61 design --- app/src/main/java/to/bitkit/models/Suggestion.kt | 4 ++-- .../main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index 6b6f0f12cd..ba1a824df1 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -52,13 +52,13 @@ enum class Suggestion( INVITE( title = R.string.cards__invite__title, description = R.string.cards__invite__description, - color = Colors.Blue24, + color = Colors.White16, icon = R.drawable.group ), PROFILE( title = R.string.cards__slashtagsProfile__title, description = R.string.cards__slashtagsProfile__description, - color = Colors.PubkyGreen24, + color = Colors.Brand24, icon = R.drawable.crown, ), SHOP( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index ba243b41d1..d0a4aafa68 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -343,8 +343,8 @@ class HomeViewModel @Inject constructor( ) = listOfNotNull( Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled }, Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted }, - Suggestion.SHOP, Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, + Suggestion.SHOP, Suggestion.PROFILE.takeIf { !profileAuthenticated }, Suggestion.SUPPORT, Suggestion.INVITE, From 658d6b0297a0b31ae16c48fb6226552d16c2f923 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 01:36:19 +0200 Subject: [PATCH 11/37] feat: show received sheet for new hw wallet txs --- .../main/java/to/bitkit/models/HwWallet.kt | 7 ++ .../to/bitkit/repositories/HwWalletRepo.kt | 24 ++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 14 ++++ .../bitkit/repositories/HwWalletRepoTest.kt | 84 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 4 + changelog.d/next/999.added.md | 2 +- 6 files changed, 134 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt index ff0fd8d0a3..4ffcdfa65d 100644 --- a/app/src/main/java/to/bitkit/models/HwWallet.kt +++ b/app/src/main/java/to/bitkit/models/HwWallet.kt @@ -27,6 +27,13 @@ data class HwWalletBalance( val sats: ULong, ) +/** A newly detected inbound transaction to a watched hardware wallet. */ +@Immutable +data class HwWalletReceivedTx( + val txid: String, + val sats: ULong, +) + @Serializable enum class HwTransportType { @SerialName("bluetooth") diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index b4a9e90824..5a913dfa49 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -12,9 +12,12 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -26,7 +29,9 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.create +import to.bitkit.ext.rawId import to.bitkit.models.HwWallet +import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork @@ -61,6 +66,11 @@ class HwWalletRepo @Inject constructor( private val activeWatchers = mutableSetOf() private val _watcherData = MutableStateFlow>(emptyMap()) + private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) + + /** Inbound transactions detected by a running watcher after its initial history sync. */ + val receivedTxs: SharedFlow = _receivedTxs.asSharedFlow() + val wallets: StateFlow> = combine( hwWalletStore.data, trezorRepo.state, @@ -97,14 +107,28 @@ class HwWalletRepo @Inject constructor( scope.launch { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect + val previous = _watcherData.value[watcherId] val activities = event.transactions.map { it.toOnchainActivity(clock) }.toImmutableList() _watcherData.update { it + (watcherId to HwWatcherData(watcherId.toDeviceId(), event.balance.total, activities)) } + emitReceivedTxs(previous, event) } } } + /** + * The first event after a watcher starts delivers the full transaction history; + * treat it as the baseline so only transactions arriving while watching are emitted. + */ + private suspend fun emitReceivedTxs(previous: HwWatcherData?, event: WatcherEvent.TransactionsChanged) { + if (previous == null) return + val knownTxIds = previous.activities.map { it.rawId() }.toSet() + event.transactions + .filter { it.direction == TxDirection.RECEIVED && it.txid !in knownTxIds } + .forEach { _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = it.amount)) } + } + private fun syncWatchers() { scope.launch { combine( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 52c10c5235..0c06efac95 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -123,6 +123,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification @@ -181,6 +182,7 @@ class AppViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, private val walletRepo: WalletRepo, + private val hwWalletRepo: HwWalletRepo, private val backupRepo: BackupRepo, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, @@ -316,6 +318,18 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } + viewModelScope.launch { + hwWalletRepo.receivedTxs.collect { tx -> + showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = tx.txid, + sats = tx.sats.toLong(), + ), + ) + } + } viewModelScope.launch { widgetsRepo.refreshEnabledWidgets() } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 25922b9450..0101ea8150 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -23,6 +24,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.models.HwTransportType +import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -149,6 +151,88 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull()) } + @Test + fun `emits received tx only for new inbound transactions after the baseline sync`() = test { + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + + // Baseline: full history delivered on watcher start must not emit. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = listOf(receivedTransaction(amount = 100uL)), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(0, received.size) + + // New inbound tx after the baseline emits once. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 150uL), + transactions = listOf( + receivedTransaction(amount = 100uL), + receivedTransaction(amount = 50uL).copy(txid = "t2"), + ), + txCount = 2u, + blockHeight = 2u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + + // Re-delivering the same set (e.g. confirmation update) must not emit again. + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 150uL), + transactions = listOf( + receivedTransaction(amount = 100uL), + receivedTransaction(amount = 50uL).copy(txid = "t2"), + ), + txCount = 2u, + blockHeight = 3u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(1, received.size) + + job.cancel() + } + + @Test + fun `does not emit received tx for new outbound transactions`() = test { + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 40uL), + transactions = listOf( + receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), + ), + txCount = 1u, + blockHeight = 2u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(0, received.size) + job.cancel() + } + @Test fun `starts watchers on the network configured in Env`() = test { storeData.value = HwWalletData( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 10428d9e5a..342b5c2b61 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -50,6 +50,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaymentPendingException @@ -94,6 +95,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() private val walletRepo = mock() + private val hwWalletRepo = mock() private val settingsStore = mock() private val currencyRepo = mock() private val connectivityRepo = mock() @@ -145,6 +147,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(hwWalletRepo.receivedTxs).thenReturn(MutableSharedFlow()) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) @@ -219,6 +222,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { lightningRepo = lightningRepo, pendingPaymentRepo = pendingPaymentRepo, walletRepo = walletRepo, + hwWalletRepo = hwWalletRepo, backupRepo = backupRepo, settingsStore = settingsStore, currencyRepo = currencyRepo, diff --git a/changelog.d/next/999.added.md b/changelog.d/next/999.added.md index 3cdfb1761a..47180efa36 100644 --- a/changelog.d/next/999.added.md +++ b/changelog.d/next/999.added.md @@ -1 +1 @@ -Show paired Trezor hardware wallet balances and activity on the wallet home screen. +Show paired Trezor hardware wallet balances and activity on the wallet home screen, including a received sheet for new incoming transactions. From d8366ead9b73509eab4b158ca9824bdd46b2d295 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 01:54:14 +0200 Subject: [PATCH 12/37] fix: use green tint for invite suggestion card --- app/src/main/java/to/bitkit/models/Suggestion.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index ba1a824df1..67dcd4c4b8 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -52,7 +52,7 @@ enum class Suggestion( INVITE( title = R.string.cards__invite__title, description = R.string.cards__invite__description, - color = Colors.White16, + color = Colors.Green24, icon = R.drawable.group ), PROFILE( From 79115eed6120bdda367811351c1288de36a023a7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 02:14:56 +0200 Subject: [PATCH 13/37] fix: vendor-prefixed hw device name on home tile --- .../to/bitkit/repositories/HwWalletRepo.kt | 14 +++++- .../bitkit/ui/screens/wallets/HomeScreen.kt | 48 +++++++++++-------- .../bitkit/repositories/HwWalletRepoTest.kt | 18 +++++++ 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 5a913dfa49..b28ae06efe 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -80,7 +80,7 @@ class HwWalletRepo @Inject constructor( val deviceWatchers = watcherData.values.filter { it.deviceId == device.id } HwWallet( id = device.id, - name = device.label ?: device.model ?: "Trezor", + name = device.displayName, model = device.model, transportType = device.transportType, isConnected = trezorState.connectedDeviceId == device.id, @@ -197,6 +197,18 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } +/** + * 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"). + */ +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" + } + private data class HwWatcherData( val deviceId: String, val balanceSats: ULong, 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 821e6b4efc..1fc4109f2a 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 @@ -740,31 +740,41 @@ private fun BalancesSection( ) } - // Hardware wallets flow into a 2-column grid: a second device fills the - // bottom-right column, additional devices wrap onto new rows. - hardwareWallets.chunked(2).forEach { rowWallets -> - VerticalSpacer(16.dp) - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - ) { - HardwareWalletCell(wallet = rowWallets[0], onClick = onClickHardwareWallet) - VerticalDivider(color = Colors.Gray4) - HorizontalSpacer(16.dp) - val second = rowWallets.getOrNull(1) - if (second != null) { - HardwareWalletCell(wallet = second, onClick = onClickHardwareWallet) - } else { - FillWidth() - } + HwDevices(wallets = hardwareWallets, onClick = onClickHardwareWallet) + } +} + +/** + * Hardware wallets flow into a 2-column grid below the Savings/Spending tiles: + * a second device fills the bottom-right column, additional devices wrap onto new rows. + */ +@Composable +private fun HwDevices( + wallets: ImmutableList, + onClick: () -> Unit, +) { + wallets.chunked(2).forEach { rowWallets -> + VerticalSpacer(16.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + HwDeviceCell(wallet = rowWallets[0], onClick = onClick) + VerticalDivider(color = Colors.Gray4) + HorizontalSpacer(16.dp) + val second = rowWallets.getOrNull(1) + if (second != null) { + HwDeviceCell(wallet = second, onClick = onClick) + } else { + FillWidth() } } } } @Composable -private fun RowScope.HardwareWalletCell( +private fun RowScope.HwDeviceCell( wallet: HwWallet, onClick: () -> Unit, ) { diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 0101ea8150..69f763c886 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -79,6 +79,24 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(0uL, sut.totalSats.value) } + @Test + fun `uses vendor-prefixed model as name when device label is missing`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(label = null, model = "Safe 7"))) + + val sut = createRepo() + + assertEquals("Trezor Safe 7", sut.wallets.value.single().name) + } + + @Test + fun `uses vendor-prefixed model as name when device label is the factory default`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(label = "Safe 7", model = "Safe 7"))) + + val sut = createRepo() + + assertEquals("Trezor Safe 7", sut.wallets.value.single().name) + } + @Test fun `transactions changed event sets device balance and maps activity`() = test { val sut = createRepo() From a47a295044874b2e91e8b78da9dbc7cdafd2e1d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 02:29:34 +0200 Subject: [PATCH 14/37] fix: dedupe hw wallet paired over multiple transports --- .../to/bitkit/repositories/HwWalletRepo.kt | 46 +++++++++++++----- .../bitkit/repositories/HwWalletRepoTest.kt | 47 +++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index b28ae06efe..613baf6db2 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -76,18 +76,29 @@ class HwWalletRepo @Inject constructor( trezorRepo.state, _watcherData, ) { data, trezorState, watcherData -> - data.knownDevices.map { device -> - val deviceWatchers = watcherData.values.filter { it.deviceId == device.id } - HwWallet( - id = device.id, - name = device.displayName, - model = device.model, - transportType = device.transportType, - isConnected = trezorState.connectedDeviceId == device.id, - balanceSats = deviceWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, - activities = deviceWatchers.flatMap { it.activities }.toImmutableList(), - ) - }.toImmutableList() + // The same physical device paired over both bluetooth and usb is stored as two + // entries with different transport-level ids; its xpubs are the cross-transport + // identity, so group by them to show one wallet and count its balance once. + data.knownDevices + .groupBy { it.walletKey } + .map { (_, devices) -> + val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId } + val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } + val ids = devices.map { it.id }.toSet() + val deviceWatchers = watcherData.values.filter { it.deviceId in ids } + HwWallet( + id = device.id, + name = device.displayName, + model = device.model, + transportType = device.transportType, + isConnected = connectedDevice != null, + balanceSats = deviceWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, + activities = deviceWatchers.flatMap { it.activities } + .distinctBy { it.rawId() } + .toImmutableList(), + ) + } + .toImmutableList() }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) val totalSats: StateFlow = wallets @@ -140,11 +151,12 @@ class HwWalletRepo @Inject constructor( // Only watch the address types the user monitors (Settings > Advanced > Address Type), // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so // toggling a type on later starts its watcher without reconnecting the device. + // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. val filtered = knownDevices.flatMap { device -> device.xpubs .filterKeys { it in monitoredTypes } .map { (addressType, xpub) -> WatcherSpec(device.id, addressType, xpub) } - } + }.distinctBy { it.addressType to it.xpub } val filteredIds = filtered.map { it.watcherId }.toSet() filtered.forEach { spec -> @@ -197,6 +209,14 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } +/** + * Cross-transport identity of the wallet a device entry tracks: entries created by + * pairing the same physical device over different transports share the same xpubs. + * Entries without captured xpubs fall back to their own transport-level id. + */ +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 diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 69f763c886..df844afc5e 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -251,6 +251,53 @@ class HwWalletRepoTest : BaseUnitTest() { job.cancel() } + @Test + fun `shows one wallet without double counting when paired over bluetooth and usb`() = test { + val bleEntry = device.copy(id = "ble1", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS")) + val usbEntry = bleEntry.copy(id = "usb1", transportType = HwTransportType.USB, lastConnectedAt = 2L) + storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + val sut = createRepo() + + verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull()) + verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull()) + + watcherEvents.emit( + "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 421_900uL), + transactions = listOf(receivedTransaction(amount = 421_900uL)), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + val wallet = sut.wallets.value.single() + assertEquals(421_900uL, wallet.balanceSats) + assertEquals(421_900uL, sut.totalSats.value) + assertEquals(1, wallet.activities.size) + assertEquals(HwTransportType.USB, wallet.transportType) + } + + @Test + fun `connected entry wins identity for a wallet paired over both transports`() = test { + val bleEntry = device.copy(id = "ble1", lastConnectedAt = 2L, xpubs = mapOf("nativeSegwit" to "zpubNS")) + val usbEntry = bleEntry.copy(id = "usb1", transportType = HwTransportType.USB, lastConnectedAt = 1L) + storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "usb1", features = mock()), + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + + val sut = createRepo() + + val wallet = sut.wallets.value.single() + assertEquals("usb1", wallet.id) + assertEquals(HwTransportType.USB, wallet.transportType) + assertEquals(true, wallet.isConnected) + } + @Test fun `starts watchers on the network configured in Env`() = test { storeData.value = HwWalletData( From a61bfc7105f1f6d85adc0612383c8a2b9ff8f16d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 02:48:45 +0200 Subject: [PATCH 15/37] fix: observe trezor disconnects for app lifetime --- .../java/to/bitkit/repositories/TrezorRepo.kt | 12 ++++++++---- .../ui/screens/trezor/TrezorViewModel.kt | 1 - .../to/bitkit/repositories/TrezorRepoTest.kt | 19 +++++++++++++++++++ .../ui/screens/trezor/TrezorViewModelTest.kt | 1 - 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index afb411865e..d59eb34533 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -91,7 +91,11 @@ class TrezorRepo @Inject constructor( private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() - private val watcherCleanupScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val repoScope = CoroutineScope(SupervisorJob() + ioDispatcher) + + init { + observeExternalDisconnects() + } private val _watcherEvents = MutableSharedFlow>(extraBufferCapacity = 64) val watcherEvents: SharedFlow> = _watcherEvents.asSharedFlow() @@ -618,7 +622,7 @@ class TrezorRepo @Inject constructor( } fun stopWatcherOnCleared(watcherId: String) { - watcherCleanupScope.launch { stopWatcher(watcherId) } + repoScope.launch { stopWatcher(watcherId) } } suspend fun stopAllWatchers(): Result = withContext(ioDispatcher) { @@ -635,7 +639,7 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(error = null) } } - fun observeExternalDisconnects(scope: CoroutineScope) { + private fun observeExternalDisconnects() { trezorTransport.externalDisconnect.onEach { path -> val currentId = _state.value.connectedDeviceId ?: return@onEach val knownDevice = _state.value.knownDevices.find { it.path == path } @@ -645,7 +649,7 @@ class TrezorRepo @Inject constructor( it.copy(connected = null, error = "Device disconnected") } } - }.launchIn(scope) + }.launchIn(repoScope) } private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index bb2831a614..184ad94f95 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -56,7 +56,6 @@ class TrezorViewModel @Inject constructor( private val watcherStartScope = CoroutineScope(SupervisorJob() + bgDispatcher) init { - trezorRepo.observeExternalDisconnects(viewModelScope) observeWatcherEvents() } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 15d518f6ac..6c04110467 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -209,6 +209,25 @@ class TrezorRepoTest : BaseUnitTest() { // region connect + @Test + fun `external disconnect clears the connected device while no screen observes it`() = test { + val externalDisconnect = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.externalDisconnect).thenReturn(externalDisconnect) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + assertNotNull(sut.state.value.connected) + + externalDisconnect.emit(DEVICE_PATH) + + assertNull(sut.state.value.connected) + assertEquals("Device disconnected", sut.state.value.error) + } + @Test fun `connect should return features and update connectedDevice state`() = test { val features = mockFeatures() diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index b9002733a7..251f10c2f5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -48,7 +48,6 @@ class TrezorViewModelTest : BaseUnitTest() { whenever(trezorRepo.needsPinEntry).thenReturn(needsPinEntryFlow) whenever(trezorRepo.walletMode).thenReturn(walletModeFlow) whenever(trezorRepo.watcherEvents).thenReturn(watcherEventsFlow) - whenever(trezorRepo.observeExternalDisconnects(any())).then { } sut = createViewModel() } From 7fa31e7876ff2c49cdd3bcafe5abd1442bcad00c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:00:06 +0200 Subject: [PATCH 16/37] fix: include hw wallet activity in all activity list --- .../wallets/activity/AllActivityScreen.kt | 4 + .../components/ActivityListGrouped.kt | 4 + .../viewmodels/ActivityListViewModel.kt | 41 ++++++- .../viewmodels/ActivityListViewModelTest.kt | 115 ++++++++++++++++++ 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index 457bc3b891..e05492d000 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -44,6 +44,7 @@ fun AllActivityScreen( ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() + val hardwareIds by viewModel.hardwareIds.collectAsStateWithLifecycle() val searchText by viewModel.searchText.collectAsStateWithLifecycle() val selectedTags by viewModel.selectedTags.collectAsStateWithLifecycle() @@ -55,6 +56,7 @@ fun AllActivityScreen( AllActivityScreenContent( filteredActivities = filteredActivities, + hardwareIds = hardwareIds, searchText = searchText, onSearchTextChange = { viewModel.setSearchText(it) }, hasTagFilter = selectedTags.isNotEmpty(), @@ -76,6 +78,7 @@ fun AllActivityScreen( private fun AllActivityScreenContent( filteredActivities: ImmutableList?, searchText: String, + hardwareIds: ImmutableSet = persistentSetOf(), onSearchTextChange: (String) -> Unit, hasTagFilter: Boolean, selectedTags: ImmutableSet, @@ -129,6 +132,7 @@ private fun AllActivityScreenContent( items = filteredActivities, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = onEmptyActivityRowClick, + hardwareIds = hardwareIds, listState = listState, contentPadding = PaddingValues(top = topPadding + 16.dp), modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index d72ed42008..69e786bba6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.rawId import to.bitkit.ui.activityListViewModel @@ -54,6 +56,7 @@ fun ActivityListGrouped( contentPadding: PaddingValues = PaddingValues(top = 20.dp), activityTestTagPrefix: String = "Activity", showContactAvatar: Boolean = true, + hardwareIds: ImmutableSet = persistentSetOf(), titleProvider: @Composable (Activity) -> String? = { null }, ) { val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { @@ -117,6 +120,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), + isHardware = item.rawId() in hardwareIds, contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 5aeebe9441..fe1c86e9de 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -29,7 +29,9 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer +import to.bitkit.ext.rawId import to.bitkit.ext.timestamp +import to.bitkit.ext.txType import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo @@ -44,7 +46,7 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, - hwWalletRepo: HwWalletRepo, + private val hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -86,6 +88,10 @@ class ActivityListViewModel @Inject constructor( val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) + val hardwareIds: StateFlow> = hwWalletRepo.activities + .map { activities -> activities.map { it.rawId() }.toImmutableSet() } + .stateInScope(persistentSetOf()) + private val _filters = MutableStateFlow(ActivityFilters()) // individual filters for UI @@ -128,13 +134,42 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, - ) { debouncedSearch, filtersWithoutSearch, _ -> - fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) + hwWalletRepo.activities, + ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities -> + val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) + fetchFilteredActivities(filters)?.let { activities -> + (activities + hardwareActivities.filteredWith(filters)).sortedByDescending { it.timestamp() } + } }.collect { activities -> _filteredActivities.update { activities?.toImmutableList() } } } + /** + * Watch-only hardware-wallet activities live outside the activity database, so the + * list filters are applied to them here. They carry no tags and are never transfers. + */ + private fun List.filteredWith(filters: ActivityFilters): List { + if (filters.tags.isNotEmpty() || filters.tab == ActivityTab.OTHER) return emptyList() + + val minTimestamp = filters.startDate?.let { (it / 1000).toULong() } + val maxTimestamp = filters.endDate?.let { (it / 1000).toULong() } + + return filter { activity -> + val matchesTab = when (filters.tab) { + ActivityTab.SENT -> activity.txType() == PaymentType.SENT + ActivityTab.RECEIVED -> activity.txType() == PaymentType.RECEIVED + else -> true + } + val matchesSearch = filters.searchText.isEmpty() || + activity.rawId().contains(filters.searchText, ignoreCase = true) + val timestamp = activity.timestamp() + val matchesDate = (minTimestamp == null || timestamp >= minTimestamp) && + (maxTimestamp == null || timestamp <= maxTimestamp) + matchesTab && matchesSearch && matchesDate + } + } + private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt new file mode 100644 index 0000000000..165e8bcc2b --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -0,0 +1,115 @@ +package to.bitkit.viewmodels + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.ext.create +import to.bitkit.ext.rawId +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.ActivityState +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.wallets.activity.components.ActivityTab +import kotlin.test.assertEquals + +class ActivityListViewModelTest : BaseUnitTest() { + + private val activityRepo = mock() + private val hwWalletRepo = mock() + private val pubkyRepo = mock() + private val settingsStore = mock() + + private val dbActivity = onchainActivity(id = "db1", txType = PaymentType.SENT, timestamp = 200uL) + private val hwActivity = onchainActivity(id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL) + + @Before + fun setUp() { + whenever(activityRepo.state).thenReturn(MutableStateFlow(ActivityState())) + whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) + whenever { activityRepo.syncActivities() }.thenReturn(Result.success(Unit)) + whenever { activityRepo.getTxIdsInBoostTxIds() }.thenReturn(emptySet()) + whenever { + activityRepo.getActivities( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + }.thenReturn(Result.success(listOf(dbActivity))) + whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) + } + + private fun createViewModel() = ActivityListViewModel( + bgDispatcher = testDispatcher, + activityRepo = activityRepo, + hwWalletRepo = hwWalletRepo, + pubkyRepo = pubkyRepo, + settingsStore = settingsStore, + ) + + @Test + fun `filtered activities merge hardware activities newest first`() = test { + val sut = createViewModel() + advanceUntilIdle() + + assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `filtered activities exclude hardware activities not matching the tab`() = test { + val sut = createViewModel() + sut.setTab(ActivityTab.SENT) + advanceUntilIdle() + + assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `filtered activities exclude hardware activities when a tag filter is active`() = test { + val sut = createViewModel() + sut.toggleTag("tag1") + advanceUntilIdle() + + assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + } + + @Test + fun `hardware ids expose the hardware activity ids`() = test { + val sut = createViewModel() + val job = launch { sut.hardwareIds.collect {} } + advanceUntilIdle() + + assertEquals(setOf("hw1"), sut.hardwareIds.value) + job.cancel() + } + + private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( + OnchainActivity.create( + id = id, + txType = txType, + txId = id, + value = 1_000uL, + fee = 1uL, + address = "bc1", + timestamp = timestamp, + confirmed = true, + ) + ) +} From c4b69cacfc584286a2194e74a41b69c4f0a646e3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:10:07 +0200 Subject: [PATCH 17/37] refactor: rename hardware sheet and transport type --- app/src/main/java/to/bitkit/models/HwWallet.kt | 12 +----------- .../java/to/bitkit/models/TransportType.kt | 14 ++++++++++++++ .../java/to/bitkit/repositories/TrezorRepo.kt | 18 +++++++++--------- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 ++-- .../java/to/bitkit/ui/components/SheetHost.kt | 4 ++-- .../ui/screens/trezor/DeviceListSection.kt | 10 +++++----- .../ui/screens/trezor/TrezorPreviewData.kt | 6 +++--- .../to/bitkit/ui/screens/wallets/HomeScreen.kt | 16 ++++++++-------- .../{ConnectSheet.kt => HardwareSheet.kt} | 16 ++++++++-------- .../to/bitkit/repositories/HwWalletRepoTest.kt | 12 ++++++------ .../to/bitkit/repositories/TrezorRepoTest.kt | 6 +++--- 11 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/TransportType.kt rename app/src/main/java/to/bitkit/ui/sheets/{ConnectSheet.kt => HardwareSheet.kt} (85%) diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt index 4ffcdfa65d..f6fffd490e 100644 --- a/app/src/main/java/to/bitkit/models/HwWallet.kt +++ b/app/src/main/java/to/bitkit/models/HwWallet.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** A paired hardware wallet tracked as a watch-only balance. */ @@ -13,7 +12,7 @@ data class HwWallet( val id: String, val name: String, val model: String?, - val transportType: HwTransportType, + val transportType: TransportType, val isConnected: Boolean, val balanceSats: ULong, val activities: ImmutableList, @@ -34,13 +33,4 @@ data class HwWalletReceivedTx( val sats: ULong, ) -@Serializable -enum class HwTransportType { - @SerialName("bluetooth") - BLUETOOTH, - - @SerialName("usb") - USB, -} - fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/models/TransportType.kt b/app/src/main/java/to/bitkit/models/TransportType.kt new file mode 100644 index 0000000000..df82a66005 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/TransportType.kt @@ -0,0 +1,14 @@ +package to.bitkit.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Transport a hardware-wallet device is paired over. */ +@Serializable +enum class TransportType { + @SerialName("bluetooth") + BLUETOOTH, + + @SerialName("usb") + USB, +} diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index d59eb34533..baea454876 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -49,7 +49,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.nowMs import to.bitkit.models.ALL_ADDRESS_TYPES -import to.bitkit.models.HwTransportType +import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString @@ -659,7 +659,7 @@ class TrezorRepo @Inject constructor( id = deviceInfo.id, name = deviceInfo.name, path = deviceInfo.path, - transportType = deviceInfo.transportType.toHwTransportType(), + transportType = deviceInfo.transportType.toTransportType(), label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, lastConnectedAt = clock.nowMs(), @@ -810,7 +810,7 @@ data class KnownDevice( val id: String, val name: String?, val path: String, - val transportType: HwTransportType, + val transportType: TransportType, val label: String?, val model: String?, val lastConnectedAt: Long, @@ -818,12 +818,12 @@ data class KnownDevice( val xpubs: Map = emptyMap(), ) -private fun TrezorTransportType.toHwTransportType(): HwTransportType = when (this) { - TrezorTransportType.BLUETOOTH -> HwTransportType.BLUETOOTH - TrezorTransportType.USB -> HwTransportType.USB +private fun TrezorTransportType.toTransportType(): TransportType = when (this) { + TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH + TrezorTransportType.USB -> TransportType.USB } -private fun HwTransportType.toCoreTransportType(): TrezorTransportType = when (this) { - HwTransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH - HwTransportType.USB -> TrezorTransportType.USB +private fun TransportType.toCoreTransportType(): TrezorTransportType = when (this) { + TransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH + TransportType.USB -> TrezorTransportType.USB } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b6f81850f6..ccf9a66981 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -177,7 +177,7 @@ import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet -import to.bitkit.ui.sheets.ConnectSheet +import to.bitkit.ui.sheets.HardwareSheet import to.bitkit.ui.sheets.ChangePinSheet import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet @@ -455,7 +455,7 @@ fun ContentView( Sheet.ChangePin -> ChangePinSheet(appViewModel) Sheet.DisablePin -> DisablePinSheet(appViewModel) is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) - is Sheet.Connect -> ConnectSheet( + is Sheet.Hardware -> HardwareSheet( sheet = sheet, onDismiss = { appViewModel.hideSheet() }, ) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ce809315cf..4711d22daa 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -34,7 +34,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute -import to.bitkit.ui.sheets.ConnectRoute +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.WidgetsRoute @@ -58,7 +58,7 @@ sealed interface Sheet { data object ChangePin : Sheet data object DisablePin : Sheet data class Backup(val route: BackupRoute = BackupRoute.ShowMnemonic) : Sheet - data class Connect(val route: ConnectRoute = ConnectRoute.Intro) : Sheet + data class Hardware(val route: HardwareRoute = HardwareRoute.Intro) : Sheet data class Widgets(val route: WidgetsRoute = WidgetsRoute.Gallery) : Sheet data object ActivityDateRangeSelector : Sheet data object ActivityTagSelector : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index b4cd7b878f..37958ed0fb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R -import to.bitkit.models.HwTransportType +import to.bitkit.models.TransportType import to.bitkit.repositories.KnownDevice import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB @@ -98,8 +98,8 @@ internal fun KnownDeviceCard( Icon( painter = painterResource( when (device.transportType) { - HwTransportType.BLUETOOTH -> R.drawable.ic_broadcast - HwTransportType.USB -> R.drawable.ic_git_branch + TransportType.BLUETOOTH -> R.drawable.ic_broadcast + TransportType.USB -> R.drawable.ic_git_branch } ), contentDescription = null, @@ -120,8 +120,8 @@ internal fun KnownDeviceCard( ) { Caption( text = when (device.transportType) { - HwTransportType.BLUETOOTH -> "Bluetooth" - HwTransportType.USB -> "USB" + TransportType.BLUETOOTH -> "Bluetooth" + TransportType.USB -> "USB" }, color = Colors.White50, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 1a9b39622d..b11758b18e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -19,7 +19,7 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import to.bitkit.models.HwTransportType +import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState @@ -61,7 +61,7 @@ internal object TrezorPreviewData { id = "usb-1", name = "Trezor Safe 5", path = "/dev/usb/001", - transportType = HwTransportType.USB, + transportType = TransportType.USB, label = "My Savings", model = "Safe 5", lastConnectedAt = 1_700_000_000_000L, @@ -71,7 +71,7 @@ internal object TrezorPreviewData { id = "ble-1", name = "Trezor Safe 7", path = "AA:BB:CC:DD:EE:FF", - transportType = HwTransportType.BLUETOOTH, + transportType = TransportType.BLUETOOTH, label = "Daily Wallet", model = "Safe 7", lastConnectedAt = 1_700_000_000_000L, 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 1fc4109f2a..58f04796a1 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 @@ -116,7 +116,7 @@ import to.bitkit.ext.rawId import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem -import to.bitkit.models.HwTransportType +import to.bitkit.models.TransportType import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion @@ -289,7 +289,7 @@ fun HomeScreen( } Suggestion.HARDWARE -> { - appViewModel.showSheet(Sheet.Connect()) + appViewModel.showSheet(Sheet.Hardware()) } Suggestion.LIGHTNING -> { @@ -385,7 +385,7 @@ fun HomeScreen( scope.launch { ToastEventBus.send( type = Toast.ToastType.WARNING, - title = "Hardware Overview not yet implemented.", + title = "Hardware wallet overview not yet implemented.", ) } }, @@ -791,8 +791,8 @@ private fun RowScope.HwDeviceCell( Icon( painter = painterResource( id = when (wallet.transportType) { - HwTransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected - HwTransportType.USB -> R.drawable.ic_usb_connected + TransportType.BLUETOOTH -> R.drawable.ic_bluetooth_connected + TransportType.USB -> R.drawable.ic_usb_connected } ), contentDescription = null, @@ -1492,7 +1492,7 @@ private val previewHardwareWalletBt = HwWallet( id = "trezor-1", name = "Trezor Safe 5", model = "Safe 5", - transportType = HwTransportType.BLUETOOTH, + transportType = TransportType.BLUETOOTH, isConnected = true, balanceSats = 10_562_411uL, activities = persistentListOf(), @@ -1501,7 +1501,7 @@ private val previewHardwareWalletUsb = HwWallet( id = "trezor-2", name = "Trezor Model T", model = "Model T", - transportType = HwTransportType.USB, + transportType = TransportType.USB, isConnected = false, balanceSats = 2_735_180uL, activities = persistentListOf(), @@ -1510,7 +1510,7 @@ private val previewHardwareWalletThird = HwWallet( id = "trezor-3", name = "Trezor Safe 3", model = "Safe 3", - transportType = HwTransportType.BLUETOOTH, + transportType = TransportType.BLUETOOTH, isConnected = true, balanceSats = 500_000uL, activities = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt similarity index 85% rename from app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt rename to app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 7ad0b79d3c..6df775359b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/ConnectSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -26,8 +26,8 @@ import to.bitkit.ui.utils.composableWithDefaultTransitions * real steps land in the dedicated connect-flow subtask. */ @Composable -fun ConnectSheet( - sheet: Sheet.Connect, +fun HardwareSheet( + sheet: Sheet.Hardware, onDismiss: () -> Unit, ) { val navController = rememberNavController() @@ -36,21 +36,21 @@ fun ConnectSheet( modifier = Modifier .fillMaxWidth() .sheetHeight(SheetSize.MEDIUM) - .testTag("connect_sheet") + .testTag("hardware_sheet") ) { NavHost( navController = navController, startDestination = sheet.route, ) { - composableWithDefaultTransitions { - ConnectIntro(onClose = onDismiss) + composableWithDefaultTransitions { + HardwareIntro(onClose = onDismiss) } } } } @Composable -private fun ConnectIntro(onClose: () -> Unit) { +private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { SheetTopBar(titleText = "Connect Hardware", onBack = onClose) Column(modifier = Modifier.padding(horizontal = 16.dp)) { @@ -62,7 +62,7 @@ private fun ConnectIntro(onClose: () -> Unit) { } } -sealed interface ConnectRoute { +sealed interface HardwareRoute { @Serializable - data object Intro : ConnectRoute + data object Intro : HardwareRoute } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index df844afc5e..dcef825765 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -23,8 +23,8 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env -import to.bitkit.models.HwTransportType import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -48,7 +48,7 @@ class HwWalletRepoTest : BaseUnitTest() { id = "dev1", name = null, path = "ble:AA:BB", - transportType = HwTransportType.BLUETOOTH, + transportType = TransportType.BLUETOOTH, label = "Trezor", model = "Safe 5", lastConnectedAt = 0L, @@ -254,7 +254,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `shows one wallet without double counting when paired over bluetooth and usb`() = test { val bleEntry = device.copy(id = "ble1", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS")) - val usbEntry = bleEntry.copy(id = "usb1", transportType = HwTransportType.USB, lastConnectedAt = 2L) + val usbEntry = bleEntry.copy(id = "usb1", transportType = TransportType.USB, lastConnectedAt = 2L) storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) @@ -277,13 +277,13 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(421_900uL, wallet.balanceSats) assertEquals(421_900uL, sut.totalSats.value) assertEquals(1, wallet.activities.size) - assertEquals(HwTransportType.USB, wallet.transportType) + assertEquals(TransportType.USB, wallet.transportType) } @Test fun `connected entry wins identity for a wallet paired over both transports`() = test { val bleEntry = device.copy(id = "ble1", lastConnectedAt = 2L, xpubs = mapOf("nativeSegwit" to "zpubNS")) - val usbEntry = bleEntry.copy(id = "usb1", transportType = HwTransportType.USB, lastConnectedAt = 1L) + val usbEntry = bleEntry.copy(id = "usb1", transportType = TransportType.USB, lastConnectedAt = 1L) storeData.value = HwWalletData(knownDevices = listOf(bleEntry, usbEntry)) trezorState.value = TrezorState( connected = ConnectedTrezorDevice(id = "usb1", features = mock()), @@ -294,7 +294,7 @@ class HwWalletRepoTest : BaseUnitTest() { val wallet = sut.wallets.value.single() assertEquals("usb1", wallet.id) - assertEquals(HwTransportType.USB, wallet.transportType) + assertEquals(TransportType.USB, wallet.transportType) assertEquals(true, wallet.isConnected) } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 6c04110467..c7ed3a787d 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -25,7 +25,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.HwWalletStore import to.bitkit.env.Env -import to.bitkit.models.HwTransportType +import to.bitkit.models.TransportType import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler @@ -127,7 +127,7 @@ class TrezorRepoTest : BaseUnitTest() { id = id, name = name, path = path, - transportType = HwTransportType.USB, + transportType = TransportType.USB, label = label, model = model, lastConnectedAt = 123L, @@ -264,7 +264,7 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(captor.capture()) val saved = captor.firstValue.single() assertEquals(DEVICE_ID, saved.id) - assertEquals(HwTransportType.USB, saved.transportType) + assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) } From 6ef4a7be472599a70c30df955455d8254236e772 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:14:46 +0200 Subject: [PATCH 18/37] fix: add gradient bg and preview to hardware sheet --- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 6df775359b..1dcbe35aef 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -6,17 +6,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetSize 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.composableWithDefaultTransitions @@ -36,6 +40,7 @@ fun HardwareSheet( modifier = Modifier .fillMaxWidth() .sheetHeight(SheetSize.MEDIUM) + .gradientBackground() .testTag("hardware_sheet") ) { NavHost( @@ -66,3 +71,20 @@ sealed interface HardwareRoute { @Serializable data object Intro : HardwareRoute } + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.MEDIUM, isModal = true) + .gradientBackground() + ) { + HardwareIntro(onClose = {}) + } + } + } +} From 14a75ccb5f82cdb9743ea0f89aafb94fa381eb00 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:16:12 +0200 Subject: [PATCH 19/37] refactor: rename trezor repo scope for consistency --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index baea454876..8d48f22855 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -91,7 +91,7 @@ class TrezorRepo @Inject constructor( private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() - private val repoScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) init { observeExternalDisconnects() @@ -622,7 +622,7 @@ class TrezorRepo @Inject constructor( } fun stopWatcherOnCleared(watcherId: String) { - repoScope.launch { stopWatcher(watcherId) } + scope.launch { stopWatcher(watcherId) } } suspend fun stopAllWatchers(): Result = withContext(ioDispatcher) { @@ -649,7 +649,7 @@ class TrezorRepo @Inject constructor( it.copy(connected = null, error = "Device disconnected") } } - }.launchIn(repoScope) + }.launchIn(scope) } private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { From a4a85de633f7ab41d67be16d3dda128702bcb839 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:20:11 +0200 Subject: [PATCH 20/37] fix: localize hardware sheet title --- app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt | 4 +++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 1dcbe35aef..504440238d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -6,11 +6,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.PrimaryButton @@ -57,7 +59,7 @@ fun HardwareSheet( @Composable private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { - SheetTopBar(titleText = "Connect Hardware", onBack = onClose) + SheetTopBar(titleText = stringResource(R.string.hardware__connect_title), onBack = onClose) Column(modifier = Modifier.padding(horizontal = 16.dp)) { BodyM(text = "Hardware wallet connect flow is not yet implemented.", color = Colors.White64) VerticalSpacer(24.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bd3f9447a..ef03338f99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ ±1-2 hours ±1h Slow + Connect Hardware 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 From 8572ef34278c8237631d2ddb26a60ce18fa72993 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:21:25 +0200 Subject: [PATCH 21/37] fix: use localized cancel in hardware sheet --- app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 504440238d..241f4b453f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -63,7 +63,7 @@ private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { BodyM(text = "Hardware wallet connect flow is not yet implemented.", color = Colors.White64) VerticalSpacer(24.dp) - PrimaryButton(text = "Close", onClick = onClose) + PrimaryButton(text = stringResource(R.string.common__cancel), onClick = onClose) VerticalSpacer(16.dp) } } From 097f00da99cf2f6a094638ec194c1f6dba502144 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:25:31 +0200 Subject: [PATCH 22/37] fix: hardware sheet intro title per figma --- app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 241f4b453f..b0a4465edc 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -59,7 +59,7 @@ fun HardwareSheet( @Composable private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { - SheetTopBar(titleText = stringResource(R.string.hardware__connect_title), onBack = onClose) + SheetTopBar(titleText = stringResource(R.string.hardware__intro_title), onBack = onClose) Column(modifier = Modifier.padding(horizontal = 16.dp)) { BodyM(text = "Hardware wallet connect flow is not yet implemented.", color = Colors.White64) VerticalSpacer(24.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef03338f99..7fd1b6c7fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,7 +163,7 @@ ±1-2 hours ±1h Slow - Connect Hardware + Hardware Wallet 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 From 281ace4e389cc34f459c9e7dc6a017e720c93864 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:36:29 +0200 Subject: [PATCH 23/37] feat: auto-reconnect hw device on transport restore --- .../java/to/bitkit/repositories/TrezorRepo.kt | 18 +++++++++ .../services/ConnectionStateReceiver.kt | 22 +++++++---- .../to/bitkit/services/TrezorTransport.kt | 12 +++++- .../to/bitkit/repositories/TrezorRepoTest.kt | 38 +++++++++++++++++++ 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 8d48f22855..874c0c5b6b 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -65,6 +65,7 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -86,6 +87,7 @@ class TrezorRepo @Inject constructor( private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0" private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L + private val TRANSPORT_RESTORED_RECONNECT_DELAY = 1.seconds } private val _state = MutableStateFlow(TrezorState()) @@ -95,6 +97,7 @@ class TrezorRepo @Inject constructor( init { observeExternalDisconnects() + observeTransportRestored() } private val _watcherEvents = MutableSharedFlow>(extraBufferCapacity = 64) @@ -652,6 +655,21 @@ class TrezorRepo @Inject constructor( }.launchIn(scope) } + /** + * Silently reconnects to a known device when its transport comes back: stored THP + * credentials make the connect prompt-free, so the link indicator recovers on its + * own after Bluetooth is re-enabled or the device is plugged back in. + */ + private fun observeTransportRestored() { + trezorTransport.transportRestored.onEach { + val current = _state.value + if (current.connected != null || current.isConnecting || current.isAutoReconnecting) return@onEach + delay(TRANSPORT_RESTORED_RECONNECT_DELAY) + Logger.info("Detected transport restored, attempting auto-reconnect", context = TAG) + autoReconnect() + }.launchIn(scope) + } + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices val previous = existing.find { it.id == deviceInfo.id } diff --git a/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt index c968957639..efd204761c 100644 --- a/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt +++ b/app/src/main/java/to/bitkit/services/ConnectionStateReceiver.kt @@ -11,22 +11,24 @@ import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat /** - * Surfaces device link loss the per-connection callbacks miss: the phone's - * Bluetooth being switched off, or a USB device being unplugged. Transport-agnostic - * so any hardware-wallet transport (Trezor today, other vendors later) can plug its - * own handling into the same system events. + * Surfaces device link changes the per-connection callbacks miss: the phone's + * Bluetooth being switched off or back on, and a USB device being unplugged or + * plugged in. Transport-agnostic so any hardware-wallet transport (Trezor today, + * other vendors later) can plug its own handling into the same system events. */ class ConnectionStateReceiver( private val onBluetoothOff: () -> Unit, + private val onBluetoothOn: () -> Unit, private val onUsbDetached: (path: String) -> Unit, + private val onUsbAttached: (device: UsbDevice) -> Unit, ) : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { when (intent.action) { BluetoothAdapter.ACTION_STATE_CHANGED -> { - val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) - if (state == BluetoothAdapter.STATE_OFF || state == BluetoothAdapter.STATE_TURNING_OFF) { - onBluetoothOff() + when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) { + BluetoothAdapter.STATE_OFF, BluetoothAdapter.STATE_TURNING_OFF -> onBluetoothOff() + BluetoothAdapter.STATE_ON -> onBluetoothOn() } } @@ -34,6 +36,11 @@ class ConnectionStateReceiver( val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) device?.deviceName?.let(onUsbDetached) } + + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + device?.let(onUsbAttached) + } } } @@ -41,6 +48,7 @@ class ConnectionStateReceiver( val filter = IntentFilter().apply { addAction(BluetoothAdapter.ACTION_STATE_CHANGED) addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) } ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 1e1be110c1..ee0f696867 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -121,6 +121,11 @@ class TrezorTransport @Inject constructor( private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) val externalDisconnect: SharedFlow = _externalDisconnect + private val _transportRestored = MutableSharedFlow(extraBufferCapacity = 1) + + /** Emits when a transport becomes available again: Bluetooth back on or a Trezor plugged in. */ + val transportRestored: SharedFlow = _transportRestored + @Volatile private var espMigrated = false @@ -162,7 +167,8 @@ class TrezorTransport @Inject constructor( /** * Feeds Bluetooth-off and USB-unplug events into the same [externalDisconnect] * flow so the repo clears the connected device and the UI connection indicator - * reflects reality in real time. + * reflects reality in real time. Bluetooth coming back on or a Trezor being + * plugged in emits [transportRestored] so the repo can silently reconnect. */ private val connectionStateReceiver = ConnectionStateReceiver( onBluetoothOff = { @@ -171,9 +177,13 @@ class TrezorTransport @Inject constructor( emitExternalDisconnect(path) } }, + onBluetoothOn = { _transportRestored.tryEmit(Unit) }, onUsbDetached = { path -> if (path in usbConnections.keys) emitExternalDisconnect(path) }, + onUsbAttached = { device -> + if (isTrezorDevice(device)) _transportRestored.tryEmit(Unit) + }, ) private fun emitExternalDisconnect(path: String) { diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index c7ed3a787d..5153fbd576 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -11,6 +11,7 @@ import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.WalletSelection import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Rule import org.junit.Test @@ -74,6 +75,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(prefsEditor.putString(any(), any())).thenReturn(prefsEditor) whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(false)) whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) + whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) whenever(context.filesDir).thenReturn(tempFolder.root) @@ -209,6 +211,42 @@ class TrezorRepoTest : BaseUnitTest() { // region connect + @Test + fun `transport restored auto-reconnects to a known device`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + transportRestored.emit(Unit) + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + } + + @Test + fun `transport restored does not reconnect when a device is already connected`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + + transportRestored.emit(Unit) + advanceUntilIdle() + + verify(trezorService, times(1)).scan() + } + @Test fun `external disconnect clears the connected device while no screen observes it`() = test { val externalDisconnect = MutableSharedFlow() From 7b78b466d4b096446d372f6c62bd3251cc1a1d68 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 03:44:53 +0200 Subject: [PATCH 24/37] feat: implement hardware connect intro screen --- .../main/java/to/bitkit/models/Suggestion.kt | 2 +- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 73 +++++++++++++++--- app/src/main/res/drawable-nodpi/ledger.webp | Bin 0 -> 97258 bytes .../{trezor_device.webp => trezor.webp} | Bin app/src/main/res/values/strings.xml | 2 + 5 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable-nodpi/ledger.webp rename app/src/main/res/drawable-nodpi/{trezor_device.webp => trezor.webp} (100%) diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index 67dcd4c4b8..c66ee2ffb5 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -23,7 +23,7 @@ enum class Suggestion( title = R.string.cards__hardware__title, description = R.string.cards__hardware__description, color = Colors.Blue24, - icon = R.drawable.trezor_device, + icon = R.drawable.trezor, ), LIGHTNING( title = R.string.cards__lightning__title, diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index b0a4465edc..07cc9183eb 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -1,11 +1,20 @@ package to.bitkit.ui.sheets +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.offset 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 @@ -15,7 +24,9 @@ import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.ui.components.BodyM 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.Sheet import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.VerticalSpacer @@ -25,11 +36,12 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.composableWithDefaultTransitions +import to.bitkit.ui.utils.withAccent /** * Entry point for the hardware-wallet connect flow opened from the home suggestion - * card. The multi-screen scaffolding (nav host + typed routes) is in place; the four - * real steps land in the dedicated connect-flow subtask. + * card. The intro step is final; the remaining connect steps land in the dedicated + * connect-flow subtask, which enables the Continue button. */ @Composable fun HardwareSheet( @@ -41,7 +53,7 @@ fun HardwareSheet( Column( modifier = Modifier .fillMaxWidth() - .sheetHeight(SheetSize.MEDIUM) + .sheetHeight(SheetSize.LARGE) .gradientBackground() .testTag("hardware_sheet") ) { @@ -58,12 +70,55 @@ fun HardwareSheet( @Composable private fun HardwareIntro(onClose: () -> Unit) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxSize()) { SheetTopBar(titleText = stringResource(R.string.hardware__intro_title), onBack = onClose) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - BodyM(text = "Hardware wallet connect flow is not yet implemented.", color = Colors.White64) - VerticalSpacer(24.dp) - PrimaryButton(text = stringResource(R.string.common__cancel), onClick = onClose) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(R.drawable.trezor), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.TopStart) + .offset(x = (-84).dp, y = 24.dp) + ) + Image( + painter = painterResource(R.drawable.ledger), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.TopEnd) + .offset(x = 53.dp) + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__intro_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__intro_text), color = Colors.White64) + VerticalSpacer(32.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onClose, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = {}, + enabled = false, + modifier = Modifier.weight(1f) + ) + } VerticalSpacer(16.dp) } } @@ -82,7 +137,7 @@ private fun Preview() { Column( modifier = Modifier .fillMaxWidth() - .sheetHeight(SheetSize.MEDIUM, isModal = true) + .sheetHeight(SheetSize.LARGE, isModal = true) .gradientBackground() ) { HardwareIntro(onClose = {}) diff --git a/app/src/main/res/drawable-nodpi/ledger.webp b/app/src/main/res/drawable-nodpi/ledger.webp new file mode 100644 index 0000000000000000000000000000000000000000..304b85c78880a7436f08d2215ea2c316e9f4e130 GIT binary patch literal 97258 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 XTglU$_!bouYcqo6^1d_x00000h}lT7 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-nodpi/trezor_device.webp b/app/src/main/res/drawable-nodpi/trezor.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/trezor_device.webp rename to app/src/main/res/drawable-nodpi/trezor.webp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fd1b6c7fa..e15e4c8817 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,8 @@ ±1-2 hours ±1h Slow + Add your <accent>hardware wallet</accent> + Connect your hardware device to watch or manage your long-term funds. Hardware Wallet 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> From d6c0321c80ea52533ecd15cd79b31a93789a5e66 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 04:08:21 +0200 Subject: [PATCH 25/37] fix: center hw intro visuals and pad bottom buttons --- app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 07cc9183eb..5313b7dfb0 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -7,6 +7,7 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -82,22 +83,23 @@ private fun HardwareIntro(onClose: () -> Unit) { contentDescription = null, modifier = Modifier .size(256.dp) - .align(Alignment.TopStart) - .offset(x = (-84).dp, y = 24.dp) + .align(Alignment.CenterStart) + .offset(x = (-84).dp, y = 12.dp) ) Image( painter = painterResource(R.drawable.ledger), contentDescription = null, modifier = Modifier .size(256.dp) - .align(Alignment.TopEnd) - .offset(x = 53.dp) + .align(Alignment.CenterEnd) + .offset(x = 53.dp, y = (-12).dp) ) } Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp) + .navigationBarsPadding() ) { Display(stringResource(R.string.hardware__intro_header).withAccent(accentColor = Colors.Blue)) VerticalSpacer(8.dp) From 0d3ae64665818114d690ce849ae591a0bbe7d1a5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 04:14:23 +0200 Subject: [PATCH 26/37] fix: retry stale watcher stop and drop intro back arrow --- .../to/bitkit/repositories/HwWalletRepo.kt | 9 ++++-- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 2 +- .../bitkit/repositories/HwWalletRepoTest.kt | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 613baf6db2..a8476ff80f 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -169,10 +169,13 @@ class HwWalletRepo @Inject constructor( ).onSuccess { activeWatchers += spec.watcherId } } + // A failed stop stays active so the next sync retries it; dropping it here + // would leave the orphaned watcher feeding _watcherData as a ghost balance. (activeWatchers - filteredIds).forEach { staleId -> - activeWatchers -= staleId - trezorRepo.stopWatcher(staleId) - _watcherData.update { it - staleId } + trezorRepo.stopWatcher(staleId).onSuccess { + activeWatchers -= staleId + _watcherData.update { it - staleId } + } } } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 5313b7dfb0..463b48ebdf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -72,7 +72,7 @@ fun HardwareSheet( @Composable private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxSize()) { - SheetTopBar(titleText = stringResource(R.string.hardware__intro_title), onBack = onClose) + SheetTopBar(titleText = stringResource(R.string.hardware__intro_title)) Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index dcef825765..bb8fddebb1 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -27,6 +27,7 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -298,6 +299,35 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, wallet.isConnected) } + @Test + fun `keeps a stale watcher until stopping it succeeds`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) + ) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.failure(AppError("stop failed"))) + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + transactions = emptyList(), + txCount = 0u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + // Stop fails: the watcher data must survive so the balance is not silently wrong. + settingsData.value = SettingsData(addressTypesToMonitor = emptyList()) + assertEquals(100uL, sut.totalSats.value) + + // Stop succeeds on a later sync: the watcher data is finally dropped. + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("taproot")) + assertEquals(0uL, sut.totalSats.value) + } + @Test fun `starts watchers on the network configured in Env`() = test { storeData.value = HwWalletData( From 9a16f69f932a86da99214da54b043885d47cc78e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 04:47:31 +0200 Subject: [PATCH 27/37] fix: proportional sizing for hw intro visuals --- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index 463b48ebdf..c982a13b2f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -2,7 +2,7 @@ package to.bitkit.ui.sheets import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -73,26 +73,28 @@ fun HardwareSheet( private fun HardwareIntro(onClose: () -> Unit) { Column(modifier = Modifier.fillMaxSize()) { SheetTopBar(titleText = stringResource(R.string.hardware__intro_title)) - Box( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .weight(1f) ) { + val imageSize = maxWidth * INTRO_IMAGE_SIZE_RATIO + val staggerY = maxWidth * INTRO_IMAGE_STAGGER_RATIO Image( painter = painterResource(R.drawable.trezor), contentDescription = null, modifier = Modifier - .size(256.dp) + .size(imageSize) .align(Alignment.CenterStart) - .offset(x = (-84).dp, y = 12.dp) + .offset(x = -maxWidth * INTRO_TREZOR_BLEED_RATIO, y = staggerY) ) Image( painter = painterResource(R.drawable.ledger), contentDescription = null, modifier = Modifier - .size(256.dp) + .size(imageSize) .align(Alignment.CenterEnd) - .offset(x = 53.dp, y = (-12).dp) + .offset(x = maxWidth * INTRO_LEDGER_BLEED_RATIO, y = -staggerY) ) } Column( @@ -131,6 +133,13 @@ sealed interface HardwareRoute { data object Intro : HardwareRoute } +// Proportions taken from the 375dp-wide Figma frame: 256dp visuals bleeding +// 84dp off the left edge and 53dp off the right, staggered by 12dp vertically. +private const val INTRO_IMAGE_SIZE_RATIO = 256f / 375f +private const val INTRO_TREZOR_BLEED_RATIO = 84f / 375f +private const val INTRO_LEDGER_BLEED_RATIO = 53f / 375f +private const val INTRO_IMAGE_STAGGER_RATIO = 12f / 375f + @Preview(showSystemUi = true) @Composable private fun Preview() { From 08e82575f56666fe4c6ce6f4bd89cc90a3c8d5ee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 04:53:33 +0200 Subject: [PATCH 28/37] fix: keep home toolbar visible above sheets --- .../main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt index 8309cc101e..eb37d5ad13 100644 --- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt @@ -14,6 +14,8 @@ import to.bitkit.ui.components.SheetSize import to.bitkit.ui.theme.Insets import to.bitkit.ui.theme.TopBarHeight +private val TOOLBAR_CLEARANCE = 16.dp + fun Modifier.sheetHeight( size: SheetSize = SheetSize.LARGE, isModal: Boolean = false, @@ -21,7 +23,9 @@ fun Modifier.sheetHeight( // Bottom safe-area belongs in sheet content padding; including it here moves // non-modal sheet tops down on devices with larger navigation-bar insets. val modalBottomPadding = if (isModal) Insets.Bottom + Insets.Bottom else 0.dp - val topPadding = Insets.Top + modalBottomPadding + TopBarHeight - 6.dp + // Reserve the full home toolbar block plus clearance so sheets open below it, + // keeping the toolbar visible instead of overlapping its bottom edge. + val topPadding = Insets.Top + modalBottomPadding + TopBarHeight + TOOLBAR_CLEARANCE val height = when (size) { SheetSize.LARGE -> screenHeight(minus = topPadding) // topbar visible From f06572c911853ef205829fa27b828f5e5029e0f6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 06:23:11 +0200 Subject: [PATCH 29/37] fix: retry hw auto-reconnect and reset stale session --- .../java/to/bitkit/repositories/TrezorRepo.kt | 34 ++++++++++++---- .../to/bitkit/repositories/TrezorRepoTest.kt | 39 +++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 874c0c5b6b..5feaa432ea 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -87,7 +87,8 @@ class TrezorRepo @Inject constructor( private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0" private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L - private val TRANSPORT_RESTORED_RECONNECT_DELAY = 1.seconds + private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4 + private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds } private val _state = MutableStateFlow(TrezorState()) @@ -495,9 +496,15 @@ class TrezorRepo @Inject constructor( if (!_state.value.isInitialized) { initialize(walletIndex).getOrThrow() } - if (trezorService.isConnected()) { - _state.value.connectedDevice ?: throw AppError("Connected but no features") + val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice else null + if (cachedFeatures != null) { + cachedFeatures } else { + if (trezorService.isConnected()) { + // The transport dropped underneath the session (e.g. bluetooth was + // toggled), so reset it before a fresh scan and connect. + runCatching { trezorService.disconnect() } + } val scannedDevices = scan().getOrThrow() val knownIds = knownDevices.map { it.id }.toSet() val usbDevice = scannedDevices.find { @@ -662,14 +669,25 @@ class TrezorRepo @Inject constructor( */ private fun observeTransportRestored() { trezorTransport.transportRestored.onEach { - val current = _state.value - if (current.connected != null || current.isConnecting || current.isAutoReconnecting) return@onEach - delay(TRANSPORT_RESTORED_RECONNECT_DELAY) - Logger.info("Detected transport restored, attempting auto-reconnect", context = TAG) - autoReconnect() + retryAutoReconnect() }.launchIn(scope) } + /** + * A device is often not discoverable right after its transport returns (a BLE + * Trezor takes a few seconds to advertise again), so retry the silent reconnect + * with growing delays instead of giving up on the first empty scan. + */ + private suspend fun retryAutoReconnect() { + repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> + val current = _state.value + if (current.connected != null || current.isConnecting || current.isAutoReconnecting) return + delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1)) + Logger.info("Attempting auto-reconnect after transport restored, attempt '${attempt + 1}'", context = TAG) + if (autoReconnect().isSuccess) return + } + } + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices val previous = existing.find { it.id == deviceInfo.id } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5153fbd576..9bdf4eb7af 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -229,6 +229,45 @@ class TrezorRepoTest : BaseUnitTest() { assertNotNull(sut.state.value.connected) } + @Test + fun `transport restored retries reconnect until the device is discoverable`() = test { + val transportRestored = MutableSharedFlow() + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + // A device is usually not advertising yet right after the transport returns. + whenever(trezorService.scan()).thenReturn(emptyList(), listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + transportRestored.emit(Unit) + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + verify(trezorService, times(2)).scan() + } + + @Test + fun `autoReconnect resets a stale session before scanning`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + // The core still reports a session although the transport dropped underneath it. + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + sut.initialize() + + val result = sut.autoReconnect() + + assertTrue(result.isSuccess) + verify(trezorService).disconnect() + assertNotNull(sut.state.value.connected) + } + @Test fun `transport restored does not reconnect when a device is already connected`() = test { val transportRestored = MutableSharedFlow() From f1f09bc978f4161dfc4c8c09488b1342f12e8439 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 07:48:32 +0200 Subject: [PATCH 30/37] feat: reconnect hw device on usb attach intent --- .../java/to/bitkit/repositories/HwWalletRepo.kt | 3 +++ .../java/to/bitkit/repositories/TrezorRepo.kt | 9 +++++++++ app/src/main/java/to/bitkit/ui/MainActivity.kt | 13 +++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 ++ .../to/bitkit/repositories/HwWalletRepoTest.kt | 9 +++++++++ .../to/bitkit/repositories/TrezorRepoTest.kt | 16 ++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 7 +++++++ 7 files changed, 59 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index a8476ff80f..c58a16e12e 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -71,6 +71,9 @@ class HwWalletRepo @Inject constructor( /** Inbound transactions detected by a running watcher after its initial history sync. */ val receivedTxs: SharedFlow = _receivedTxs.asSharedFlow() + /** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */ + fun onTransportRestored() = trezorRepo.onTransportRestored() + val wallets: StateFlow> = combine( hwWalletStore.data, trezorRepo.state, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 5feaa432ea..e0ef6e5c93 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -673,6 +673,15 @@ class TrezorRepo @Inject constructor( }.launchIn(scope) } + /** + * Triggers the silent reconnect for transport events delivered through UI intents, + * e.g. the USB attach intent the OS app picker routes to the activity (attach is + * not broadcast to receivers, unlike detach). + */ + fun onTransportRestored() { + scope.launch { retryAutoReconnect() } + } + /** * A device is often not discoverable right after its transport returns (a BLE * Trezor takes a few seconds to advertise again), so retry the silent reconnect diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41bfcae26c..ed8a743959 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.UsbManager import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -95,6 +96,7 @@ class MainActivity : FragmentActivity() { if (currentLaunchIntent == null || currentLaunchIntent != consumedLaunchIntent) { appViewModel.handleDeeplinkIntent(intent) } + handleUsbAttachIntent(intent) installSplashScreen() enableAppEdgeToEdge() @@ -210,6 +212,17 @@ class MainActivity : FragmentActivity() { super.onNewIntent(intent) setIntent(intent) appViewModel.handleDeeplinkIntent(intent) + handleUsbAttachIntent(intent) + } + + /** + * 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(intent: Intent) { + if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { + appViewModel.onUsbDeviceAttached() + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0c06efac95..9cf906b7b4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3046,6 +3046,8 @@ class AppViewModel @Inject constructor( } } + fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored() + fun clearPendingPubkyImport() { viewModelScope.launch { pubkyRepo.clearPendingImport() diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index bb8fddebb1..59b843db42 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -328,6 +328,15 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(0uL, sut.totalSats.value) } + @Test + fun `forwards transport restored to the trezor repo`() = test { + val sut = createRepo() + + sut.onTransportRestored() + + verify(trezorRepo).onTransportRestored() + } + @Test fun `starts watchers on the network configured in Env`() = test { storeData.value = HwWalletData( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 9bdf4eb7af..b63e29b875 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -249,6 +249,22 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(2)).scan() } + @Test + fun `onTransportRestored auto-reconnects to a known device`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.onTransportRestored() + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + } + @Test fun `autoReconnect resets a stale session before scanning`() = test { val features = mockFeatures() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 342b5c2b61..ea183b880b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -248,6 +248,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyRepo = pubkyRepo, ) + @Test + fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test { + sut.onUsbDeviceAttached() + + verify(hwWalletRepo).onTransportRestored() + } + @Test fun `canSwitchWallet is false when not unified`() = test { sut.setSendEvent(SendEvent.AmountChange(1000u)) From e60caf593db34c702643364023d14ad4536cfb9d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 08:16:30 +0200 Subject: [PATCH 31/37] feat: add pair device sheet for hw pairing code --- .../to/bitkit/repositories/HwWalletRepo.kt | 7 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 2 + .../ui/screens/trezor/PairingCodeDialog.kt | 115 ------------------ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 8 -- .../ui/screens/trezor/TrezorViewModel.kt | 21 ---- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 97 ++++++++++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 34 ++++++ app/src/main/res/values/strings.xml | 2 + .../bitkit/repositories/HwWalletRepoTest.kt | 11 ++ .../ui/screens/trezor/TrezorViewModelTest.kt | 17 --- .../viewmodels/AppViewModelSendFlowTest.kt | 41 +++++++ changelog.d/next/999.added.md | 2 +- 12 files changed, 194 insertions(+), 163 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index c58a16e12e..babc4b27bc 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -74,6 +74,13 @@ class HwWalletRepo @Inject constructor( /** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */ fun onTransportRestored() = trezorRepo.onTransportRestored() + /** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */ + val needsPairingCode = trezorRepo.needsPairingCode + + fun submitPairingCode(code: String) = trezorRepo.submitPairingCode(code) + + fun cancelPairingCode() = trezorRepo.cancelPairingCode() + val wallets: StateFlow> = combine( hwWalletStore.data, trezorRepo.state, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ccf9a66981..79c46dbfa4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -458,6 +458,8 @@ fun ContentView( is Sheet.Hardware -> HardwareSheet( sheet = sheet, onDismiss = { appViewModel.hideSheet() }, + onSubmitPairingCode = appViewModel::submitPairingCode, + onCancelPairingCode = appViewModel::cancelPairingCode, ) is Sheet.Widgets -> { WidgetsSheet( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt deleted file mode 100644 index 161c1d06db..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ /dev/null @@ -1,115 +0,0 @@ -package to.bitkit.ui.screens.trezor - -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.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.runtime.Composable -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.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Caption -import to.bitkit.ui.components.Footnote -import to.bitkit.ui.components.TertiaryButton -import to.bitkit.ui.components.Title -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -internal fun PairingCodeDialog( - onSubmit: (String) -> Unit, - onCancel: () -> Unit, -) { - var code by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onCancel, - containerColor = Colors.Gray5, - shape = MaterialTheme.shapes.medium, - title = { - Title( - text = "Enter Pairing Code", - color = Colors.White, - ) - }, - text = { - Column { - Caption( - text = "Enter the 6-digit code shown on your Trezor device:", - color = Colors.White80, - ) - VerticalSpacer(16.dp) - OutlinedTextField( - value = code, - onValueChange = { newValue -> - if (newValue.all { it.isDigit() } && newValue.length <= 6) { - code = newValue - } - }, - placeholder = { - Footnote("000000", color = Colors.White32) - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Colors.White, - unfocusedTextColor = Colors.White, - focusedBorderColor = Colors.Brand, - unfocusedBorderColor = Colors.White32, - cursorColor = Colors.Brand, - ), - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = 24.sp, - textAlign = TextAlign.Center, - letterSpacing = 8.sp, - ), - ) - } - }, - confirmButton = { - TertiaryButton( - text = "Submit", - onClick = { onSubmit(code) }, - enabled = code.length == 6, - size = ButtonSize.Small, - fullWidth = false, - ) - }, - dismissButton = { - TertiaryButton( - text = "Cancel", - onClick = onCancel, - size = ButtonSize.Small, - fullWidth = false, - ) - }, - ) -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewPairingCodeDialog() { - AppThemeSurface { - Box(Modifier.fillMaxSize()) { - PairingCodeDialog(onSubmit = {}, onCancel = {}) - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 9200f329b5..16beb3bbd7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -97,7 +97,6 @@ private fun TrezorScreenContent( ) { val trezorState by viewModel.trezorState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val needsPairingCode by viewModel.needsPairingCode.collectAsStateWithLifecycle() val needsPinEntry by viewModel.needsPinEntry.collectAsStateWithLifecycle() val walletMode by viewModel.walletMode.collectAsStateWithLifecycle() @@ -115,13 +114,6 @@ private fun TrezorScreenContent( } } - if (needsPairingCode) { - PairingCodeDialog( - onSubmit = viewModel::submitPairingCode, - onCancel = viewModel::cancelPairingCode, - ) - } - if (needsPinEntry) { PinEntryDialog( onSubmit = viewModel::submitPin, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 184ad94f95..7a2e991dc8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -121,13 +121,6 @@ class TrezorViewModel @Inject constructor( val trezorState = trezorRepo.state .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), trezorRepo.state.value) - /** - * Flow indicating when a pairing code is needed. - * UI should show a dialog when this is true. - */ - val needsPairingCode = trezorRepo.needsPairingCode - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val needsPinEntry = trezorRepo.needsPinEntry .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -817,20 +810,6 @@ class TrezorViewModel @Inject constructor( trezorRepo.clearError() } - /** - * Submit the pairing code entered by the user. - */ - fun submitPairingCode(code: String) { - trezorRepo.submitPairingCode(code) - } - - /** - * Cancel pairing code entry. - */ - fun cancelPairingCode() { - trezorRepo.cancelPairingCode() - } - fun submitPin(pin: String) { trezorRepo.submitPin(pin) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index c982a13b2f..c68611316c 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -12,11 +12,19 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost @@ -26,6 +34,9 @@ import to.bitkit.R 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.KEY_DELETE +import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Sheet @@ -41,13 +52,16 @@ import to.bitkit.ui.utils.withAccent /** * Entry point for the hardware-wallet connect flow opened from the home suggestion - * card. The intro step is final; the remaining connect steps land in the dedicated + * card, and host of the Pair Device screen shown app-wide when the device asks for + * its one-time pairing code. The remaining connect steps land in the dedicated * connect-flow subtask, which enables the Continue button. */ @Composable fun HardwareSheet( sheet: Sheet.Hardware, onDismiss: () -> Unit, + onSubmitPairingCode: (String) -> Unit, + onCancelPairingCode: () -> Unit, ) { val navController = rememberNavController() @@ -65,6 +79,12 @@ fun HardwareSheet( composableWithDefaultTransitions { HardwareIntro(onClose = onDismiss) } + composableWithDefaultTransitions { + HardwarePairing( + onSubmit = onSubmitPairingCode, + onCancel = onCancelPairingCode, + ) + } } } } @@ -128,11 +148,69 @@ private fun HardwareIntro(onClose: () -> Unit) { } } +@Composable +private fun HardwarePairing( + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var code by remember { mutableStateOf("") } + var submitted by remember { mutableStateOf(false) } + + // Dismissing the sheet without submitting (e.g. swipe down) cancels the pending + // pairing request so the connect attempt does not hang until its timeout. + DisposableEffect(Unit) { + onDispose { if (!submitted) onCancel() } + } + + Column(modifier = Modifier.fillMaxSize()) { + SheetTopBar(titleText = stringResource(R.string.hardware__pairing_title)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 32.dp) + ) { + BodyM(stringResource(R.string.hardware__pairing_text), color = Colors.White64) + FillHeight() + Display( + buildAnnotatedString { + append(code) + withStyle(SpanStyle(color = Colors.White32)) { + repeat(PAIRING_CODE_LENGTH - code.length) { append('•') } + } + } + ) + FillHeight() + } + NumberPad( + onPress = { key -> + when { + key == KEY_DELETE -> code = code.dropLast(1) + code.length < PAIRING_CODE_LENGTH -> { + code += key + if (code.length == PAIRING_CODE_LENGTH) { + submitted = true + onSubmit(code) + } + } + } + }, + includeNavigationBarsPadding = true, + ) + } +} + sealed interface HardwareRoute { @Serializable data object Intro : HardwareRoute + + @Serializable + data object PairingCode : HardwareRoute } +private const val PAIRING_CODE_LENGTH = 6 + // Proportions taken from the 375dp-wide Figma frame: 256dp visuals bleeding // 84dp off the left edge and 53dp off the right, staggered by 12dp vertically. private const val INTRO_IMAGE_SIZE_RATIO = 256f / 375f @@ -156,3 +234,20 @@ private fun Preview() { } } } + +@Preview(showSystemUi = true) +@Composable +private fun PairingPreview() { + AppThemeSurface { + BottomSheetPreview { + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE, isModal = true) + .gradientBackground() + ) { + HardwarePairing(onSubmit = {}, onCancel = {}) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9cf906b7b4..97a6839c2d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -144,6 +144,7 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue @@ -330,6 +331,17 @@ class AppViewModel @Inject constructor( ) } } + viewModelScope.launch { + hwWalletRepo.needsPairingCode.collect { needsCode -> + if (needsCode) { + showPairingCodeSheet() + } else { + _currentSheet.update { sheet -> + if (sheet is Sheet.Hardware && sheet.route == HardwareRoute.PairingCode) null else sheet + } + } + } + } viewModelScope.launch { widgetsRepo.refreshEnabledWidgets() } @@ -3048,6 +3060,28 @@ class AppViewModel @Inject constructor( fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored() + fun submitPairingCode(code: String) = hwWalletRepo.submitPairingCode(code) + + fun cancelPairingCode() = hwWalletRepo.cancelPairingCode() + + /** + * The device asks for its one-time pairing code mid-connect, which can happen on + * any screen via silent reconnects, so the sheet is shown app-wide. High-priority + * sheets are not interrupted: reconnect retries re-raise the request shortly after. + */ + private fun showPairingCodeSheet() { + val current = _currentSheet.value + val isHighPrioritySheetShowing = current is Sheet.Gift || + current is Sheet.Send || + current is Sheet.BTCPayConnection || + current is Sheet.LnurlAuth || + current is Sheet.Pin || + current is Sheet.PubkyAuth + if (!isHighPrioritySheetShowing) { + showSheet(Sheet.Hardware(route = HardwareRoute.PairingCode)) + } + } + fun clearPendingPubkyImport() { viewModelScope.launch { pubkyRepo.clearPendingImport() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e15e4c8817..54dceaafef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,6 +166,8 @@ Add your <accent>hardware wallet</accent> Connect your hardware device to watch or manage your long-term funds. Hardware Wallet + Enter the 6-digit code shown on your Trezor device. + Pair Device 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 59b843db42..f9c62d578e 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -337,6 +337,17 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).onTransportRestored() } + @Test + fun `forwards pairing code calls to the trezor repo`() = test { + val sut = createRepo() + + sut.submitPairingCode("123456") + sut.cancelPairingCode() + + verify(trezorRepo).submitPairingCode("123456") + verify(trezorRepo).cancelPairingCode() + } + @Test fun `starts watchers on the network configured in Env`() = test { storeData.value = HwWalletData( diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index 251f10c2f5..b1f365f592 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -34,7 +34,6 @@ class TrezorViewModelTest : BaseUnitTest() { private val trezorRepo: TrezorRepo = mock() private val trezorStateFlow = MutableStateFlow(TrezorState()) - private val needsPairingCodeFlow = MutableStateFlow(false) private val needsPinEntryFlow = MutableStateFlow(false) private val walletModeFlow = MutableStateFlow(TrezorWalletMode.STANDARD) private val watcherEventsFlow = MutableSharedFlow>() @@ -44,7 +43,6 @@ class TrezorViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(trezorRepo.state).thenReturn(trezorStateFlow) - whenever(trezorRepo.needsPairingCode).thenReturn(needsPairingCodeFlow) whenever(trezorRepo.needsPinEntry).thenReturn(needsPinEntryFlow) whenever(trezorRepo.walletMode).thenReturn(walletModeFlow) whenever(trezorRepo.watcherEvents).thenReturn(watcherEventsFlow) @@ -496,21 +494,6 @@ class TrezorViewModelTest : BaseUnitTest() { verify(trezorRepo).clearError() } - @Test - fun `submitPairingCode should call trezorRepo submitPairingCode`() { - val code = "123456" - sut.submitPairingCode(code) - - verify(trezorRepo).submitPairingCode(code) - } - - @Test - fun `cancelPairingCode should call trezorRepo cancelPairingCode`() { - sut.cancelPairingCode() - - verify(trezorRepo).cancelPairingCode() - } - @Test fun `hasKnownDevices should delegate to trezorRepo`() { whenever(trezorRepo.hasKnownDevices()).thenReturn(true) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ea183b880b..9e3a4ccaca 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -73,6 +73,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.sheets.HardwareRoute import to.bitkit.ui.sheets.SendRoute import to.bitkit.usecases.FormatMoneyValue import to.bitkit.utils.AppError @@ -123,6 +124,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val toastManager = mock() private val balanceState = MutableStateFlow(BalanceState()) + private val needsPairingCode = MutableStateFlow(false) private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) @@ -148,6 +150,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(hwWalletRepo.receivedTxs).thenReturn(MutableSharedFlow()) + whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) @@ -255,6 +258,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onTransportRestored() } + @Test + fun `pairing code request shows and hides the pair device sheet`() = test { + needsPairingCode.value = true + advanceUntilIdle() + + assertEquals(Sheet.Hardware(route = HardwareRoute.PairingCode), sut.currentSheet.value) + + needsPairingCode.value = false + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `pairing code request does not interrupt a high priority sheet`() = test { + sut.showSheet(Sheet.Pin()) + advanceUntilIdle() + + needsPairingCode.value = true + advanceUntilIdle() + + assertEquals(Sheet.Pin(), sut.currentSheet.value) + } + + @Test + fun `submitPairingCode forwards to the hardware wallet repo`() = test { + sut.submitPairingCode("123456") + + verify(hwWalletRepo).submitPairingCode("123456") + } + + @Test + fun `cancelPairingCode forwards to the hardware wallet repo`() = test { + sut.cancelPairingCode() + + verify(hwWalletRepo).cancelPairingCode() + } + @Test fun `canSwitchWallet is false when not unified`() = test { sut.setSendEvent(SendEvent.AmountChange(1000u)) diff --git a/changelog.d/next/999.added.md b/changelog.d/next/999.added.md index 47180efa36..c69580438a 100644 --- a/changelog.d/next/999.added.md +++ b/changelog.d/next/999.added.md @@ -1 +1 @@ -Show paired Trezor hardware wallet balances and activity on the wallet home screen, including a received sheet for new incoming transactions. +Show paired Trezor hardware wallet balances and activity on the wallet home screen, with automatic reconnection and pairing-code entry, and a received sheet for new incoming transactions. From ff9d4b9e0a80b9626e6edcddddff6f885476b5e8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 08:56:10 +0200 Subject: [PATCH 32/37] fix: use sync usb transfers to avoid native crash --- .../to/bitkit/services/TrezorTransport.kt | 87 +++++++------------ 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index ee0f696867..b1dbff0044 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -24,7 +24,6 @@ import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager -import android.hardware.usb.UsbRequest import android.os.Handler import android.os.Looper import android.os.ParcelUuid @@ -45,7 +44,6 @@ import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager import to.bitkit.utils.Logger import java.io.File -import java.nio.ByteBuffer import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -702,39 +700,27 @@ class TrezorTransport @Inject constructor( error = "Device not open: $path", ) - val buffer = ByteBuffer.allocate(USB_CHUNK_SIZE) - val request = UsbRequest() - try { - if (!request.initialize(openDevice.connection, openDevice.readEndpoint)) { - return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Failed to initialize USB read request", - ) - } - if (!request.queue(buffer)) { - return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Failed to queue USB read request", - ) - } - openDevice.connection.requestWait(READ_TIMEOUT_MS.toLong()) - ?: return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "USB read timed out", - ) - - buffer.flip() - val bytesRead = buffer.remaining() - val data = ByteArray(bytesRead) - buffer.get(data) - Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") - } finally { - request.close() + // Synchronous transfer on purpose: the async UsbRequest API requires + // cancelling and reaping a timed-out request before closing it; closing + // with the URB still queued frees memory the kernel later writes into + // (native SIGSEGV in libusbhost once the device finally responds). + val buffer = ByteArray(USB_CHUNK_SIZE) + val bytesRead = openDevice.connection.bulkTransfer( + openDevice.readEndpoint, + buffer, + buffer.size, + READ_TIMEOUT_MS, + ) + if (bytesRead < 0) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "USB read timed out", + ) } + + Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) + TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") @@ -747,29 +733,18 @@ class TrezorTransport @Inject constructor( val openDevice = usbConnections[path] ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") - val buffer = ByteBuffer.wrap(data) - val request = UsbRequest() - try { - if (!request.initialize(openDevice.connection, openDevice.writeEndpoint)) { - return TrezorTransportWriteResult( - success = false, - error = "Failed to initialize USB write request", - ) - } - if (!request.queue(buffer)) { - return TrezorTransportWriteResult( - success = false, - error = "Failed to queue USB write request", - ) - } - openDevice.connection.requestWait(WRITE_TIMEOUT_MS.toLong()) - ?: return TrezorTransportWriteResult(success = false, error = "USB write timed out") - - Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") - } finally { - request.close() + val bytesWritten = openDevice.connection.bulkTransfer( + openDevice.writeEndpoint, + data, + data.size, + WRITE_TIMEOUT_MS, + ) + if (bytesWritten != data.size) { + return TrezorTransportWriteResult(success = false, error = "USB write timed out") } + + Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) + TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") From 5088aadd2aa726051ce7432e7dbb0e5783f3213e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 09:06:04 +0200 Subject: [PATCH 33/37] fix: skip auto-reconnect during live hw handshake --- .../java/to/bitkit/repositories/TrezorRepo.kt | 19 ++++++++++-- .../to/bitkit/repositories/TrezorRepoTest.kt | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index e0ef6e5c93..b2397f402a 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -486,6 +486,12 @@ class TrezorRepo @Inject constructor( fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(ioDispatcher) { + if (isConnectInProgress()) { + // A live handshake looks like a stale session (transport connected, + // features pending), so resetting here would drop the session the + // user is entering their PIN or pairing code into. + return@withContext Result.failure(AppError("Connect already in progress")) + } val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } if (knownDevices.isEmpty()) { return@withContext Result.failure(AppError("No known devices")) @@ -689,14 +695,23 @@ class TrezorRepo @Inject constructor( */ private suspend fun retryAutoReconnect() { repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> - val current = _state.value - if (current.connected != null || current.isConnecting || current.isAutoReconnecting) return + if (_state.value.connected != null || isConnectInProgress()) return delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1)) + // A connect may have started while this attempt was waiting. + if (_state.value.connected != null || isConnectInProgress()) return Logger.info("Attempting auto-reconnect after transport restored, attempt '${attempt + 1}'", context = TAG) if (autoReconnect().isSuccess) return } } + private fun isConnectInProgress(): Boolean = run { + val current = _state.value + current.isConnecting || + current.isAutoReconnecting || + needsPinEntry.value || + needsPairingCode.value + } + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices val previous = existing.find { it.id == deviceInfo.id } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index b63e29b875..5d39a945f3 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -21,6 +21,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -249,6 +250,34 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(2)).scan() } + @Test + fun `autoReconnect bails while device awaits pin entry`() = test { + whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(true)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + sut = createSut() + + val result = sut.autoReconnect() + + assertTrue(result.isFailure) + verify(trezorService, never()).disconnect() + verify(trezorService, never()).scan() + } + + @Test + fun `transport restored skips reconnect while device awaits pairing code`() = test { + val transportRestored = MutableSharedFlow() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(true)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + sut = createSut() + + transportRestored.emit(Unit) + advanceUntilIdle() + + verify(trezorService, never()).disconnect() + verify(trezorService, never()).scan() + } + @Test fun `onTransportRestored auto-reconnects to a known device`() = test { val features = mockFeatures() From 7436803fd037a6b5de4c842fbe21e96142b25104 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 09:29:48 +0200 Subject: [PATCH 34/37] fix: serialize hw reconnect triggers into one loop --- .../java/to/bitkit/repositories/TrezorRepo.kt | 19 ++++++++++++++++--- .../to/bitkit/repositories/TrezorRepoTest.kt | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index b2397f402a..f0383892e5 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -31,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -96,6 +97,9 @@ class TrezorRepo @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + @Volatile + private var transportReconnectJob: Job? = null + init { observeExternalDisconnects() observeTransportRestored() @@ -675,7 +679,7 @@ class TrezorRepo @Inject constructor( */ private fun observeTransportRestored() { trezorTransport.transportRestored.onEach { - retryAutoReconnect() + launchTransportReconnect() }.launchIn(scope) } @@ -684,8 +688,17 @@ class TrezorRepo @Inject constructor( * e.g. the USB attach intent the OS app picker routes to the activity (attach is * not broadcast to receivers, unlike detach). */ - fun onTransportRestored() { - scope.launch { retryAutoReconnect() } + fun onTransportRestored() = launchTransportReconnect() + + /** + * Serializes reconnect triggers into one in-flight retry loop. A Trezor + * re-enumerates USB during its unlock flow, so a single replug delivers several + * attach intents; letting each spawn its own loop staggers connect attempts for + * many seconds, and every attempt restarts the device's PIN entry. + */ + private fun launchTransportReconnect() { + if (transportReconnectJob?.isActive == true) return + transportReconnectJob = scope.launch { retryAutoReconnect() } } /** diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5d39a945f3..88d1814edc 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -250,6 +250,24 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(2)).scan() } + @Test + fun `repeated transport restored triggers run a single reconnect`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + repeat(3) { sut.onTransportRestored() } + advanceUntilIdle() + + assertNotNull(sut.state.value.connected) + verify(trezorService, times(1)).scan() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + @Test fun `autoReconnect bails while device awaits pin entry`() = test { whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(true)) From 39ee64f66b82c0b229fc0f4caf94562d0c7fab90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 10:25:14 +0200 Subject: [PATCH 35/37] fix: stable pairing code cells and numpad focus --- .../java/to/bitkit/ui/components/NumberPad.kt | 13 +++++++++- .../java/to/bitkit/ui/sheets/HardwareSheet.kt | 25 ++++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) 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 765682f2c5..139402eaac 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay @@ -50,11 +51,14 @@ import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds const val KEY_DELETE = "delete" const val KEY_000 = "000" const val KEY_DECIMAL = "." private val defaultHeight = 300.dp +private const val FOCUS_RETRY_COUNT = 10 +private val FOCUS_RETRY_DELAY = 50.milliseconds private val idealButtonHeight = 75.dp private val minButtonHeight = 50.dp private const val ROWS = 4 @@ -78,7 +82,14 @@ fun NumberPad( onDeleteLongPress: (() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(Unit) { + // Composing mid sheet/nav transition can drop the initial request, leaving + // hardware keyboard input dead; retry briefly until the focus node takes it. + repeat(FOCUS_RETRY_COUNT) { + if (runCatching { focusRequester.requestFocus() }.isSuccess) return@LaunchedEffect + delay(FOCUS_RETRY_DELAY) + } + } val safeAreaModifier = if (includeNavigationBarsPadding) { modifier.navigationBarsPadding() } else { diff --git a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt index c68611316c..32096d54ce 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/HardwareSheet.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.sheets import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset 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 import androidx.compose.runtime.getValue @@ -22,9 +24,6 @@ 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.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost @@ -173,14 +172,21 @@ private fun HardwarePairing( ) { BodyM(stringResource(R.string.hardware__pairing_text), color = Colors.White64) FillHeight() - Display( - buildAnnotatedString { - append(code) - withStyle(SpanStyle(color = Colors.White32)) { - repeat(PAIRING_CODE_LENGTH - code.length) { append('•') } + // 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, + ) } } - ) + } FillHeight() } NumberPad( @@ -210,6 +216,7 @@ sealed interface HardwareRoute { } private const val PAIRING_CODE_LENGTH = 6 +private val PAIRING_CELL_WIDTH = 32.dp // Proportions taken from the 375dp-wide Figma frame: 256dp visuals bleeding // 84dp off the left edge and 53dp off the right, staggered by 12dp vertically. From a0e5afdb4ea85bd99148721dafecda73a5299f03 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 10:26:15 +0200 Subject: [PATCH 36/37] feat: prefer restored transport on hw reconnect --- .../to/bitkit/repositories/HwWalletRepo.kt | 3 +- .../java/to/bitkit/repositories/TrezorRepo.kt | 27 +++++++---- .../to/bitkit/services/TrezorTransport.kt | 11 +++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +- .../bitkit/repositories/HwWalletRepoTest.kt | 4 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 48 ++++++++++++++----- .../viewmodels/AppViewModelSendFlowTest.kt | 3 +- 7 files changed, 70 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index babc4b27bc..64287adad4 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -32,6 +32,7 @@ import to.bitkit.ext.create import to.bitkit.ext.rawId import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.TransportType import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork @@ -72,7 +73,7 @@ class HwWalletRepo @Inject constructor( val receivedTxs: SharedFlow = _receivedTxs.asSharedFlow() /** Forwards UI-delivered transport events, e.g. the USB attach intent from the OS app picker. */ - fun onTransportRestored() = trezorRepo.onTransportRestored() + fun onTransportRestored(transportType: TransportType) = trezorRepo.onTransportRestored(transportType) /** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */ val needsPairingCode = trezorRepo.needsPairingCode diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index f0383892e5..37240130e9 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -489,7 +489,10 @@ class TrezorRepo @Inject constructor( fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() - suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(ioDispatcher) { + suspend fun autoReconnect( + walletIndex: Int = 0, + preferredTransport: TransportType? = null, + ): Result = withContext(ioDispatcher) { if (isConnectInProgress()) { // A live handshake looks like a stale session (transport connected, // features pending), so resetting here would drop the session the @@ -523,7 +526,15 @@ class TrezorRepo @Inject constructor( val idMatch = knownDevices.firstNotNullOfOrNull { known -> scannedDevices.find { it.id == known.id } } - val match = idMatch ?: usbDevice ?: throw AppError("No known device found nearby") + // Prefer the transport that just came back, so e.g. a USB replug does + // not reconnect over BLE when the same device is known on both. + val preferredMatch = preferredTransport?.let { preferred -> + scannedDevices.find { + it.id in knownIds && it.transportType.toTransportType() == preferred + } + } + val match = preferredMatch ?: idMatch ?: usbDevice + ?: throw AppError("No known device found nearby") connect(match.id).getOrThrow() } }.onSuccess { @@ -679,7 +690,7 @@ class TrezorRepo @Inject constructor( */ private fun observeTransportRestored() { trezorTransport.transportRestored.onEach { - launchTransportReconnect() + launchTransportReconnect(it) }.launchIn(scope) } @@ -688,7 +699,7 @@ class TrezorRepo @Inject constructor( * e.g. the USB attach intent the OS app picker routes to the activity (attach is * not broadcast to receivers, unlike detach). */ - fun onTransportRestored() = launchTransportReconnect() + fun onTransportRestored(transportType: TransportType) = launchTransportReconnect(transportType) /** * Serializes reconnect triggers into one in-flight retry loop. A Trezor @@ -696,9 +707,9 @@ class TrezorRepo @Inject constructor( * attach intents; letting each spawn its own loop staggers connect attempts for * many seconds, and every attempt restarts the device's PIN entry. */ - private fun launchTransportReconnect() { + private fun launchTransportReconnect(transportType: TransportType) { if (transportReconnectJob?.isActive == true) return - transportReconnectJob = scope.launch { retryAutoReconnect() } + transportReconnectJob = scope.launch { retryAutoReconnect(transportType) } } /** @@ -706,14 +717,14 @@ class TrezorRepo @Inject constructor( * Trezor takes a few seconds to advertise again), so retry the silent reconnect * with growing delays instead of giving up on the first empty scan. */ - private suspend fun retryAutoReconnect() { + private suspend fun retryAutoReconnect(transportType: TransportType) { repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> if (_state.value.connected != null || isConnectInProgress()) return delay(TRANSPORT_RESTORED_RECONNECT_DELAY * (attempt + 1)) // A connect may have started while this attempt was waiting. if (_state.value.connected != null || isConnectInProgress()) return Logger.info("Attempting auto-reconnect after transport restored, attempt '${attempt + 1}'", context = TAG) - if (autoReconnect().isSuccess) return + if (autoReconnect(preferredTransport = transportType).isSuccess) return } } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index b1dbff0044..09c556ed84 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager +import to.bitkit.models.TransportType import to.bitkit.utils.Logger import java.io.File import java.util.UUID @@ -119,10 +120,10 @@ class TrezorTransport @Inject constructor( private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) val externalDisconnect: SharedFlow = _externalDisconnect - private val _transportRestored = MutableSharedFlow(extraBufferCapacity = 1) + private val _transportRestored = MutableSharedFlow(extraBufferCapacity = 1) - /** Emits when a transport becomes available again: Bluetooth back on or a Trezor plugged in. */ - val transportRestored: SharedFlow = _transportRestored + /** Emits the transport that became available again: Bluetooth back on or a Trezor plugged in. */ + val transportRestored: SharedFlow = _transportRestored @Volatile private var espMigrated = false @@ -175,12 +176,12 @@ class TrezorTransport @Inject constructor( emitExternalDisconnect(path) } }, - onBluetoothOn = { _transportRestored.tryEmit(Unit) }, + onBluetoothOn = { _transportRestored.tryEmit(TransportType.BLUETOOTH) }, onUsbDetached = { path -> if (path in usbConnections.keys) emitExternalDisconnect(path) }, onUsbAttached = { device -> - if (isTrezorDevice(device)) _transportRestored.tryEmit(Unit) + if (isTrezorDevice(device)) _transportRestored.tryEmit(TransportType.USB) }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 97a6839c2d..b0f01754f3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -110,6 +110,7 @@ import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.TransportType import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -3058,7 +3059,7 @@ class AppViewModel @Inject constructor( } } - fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored() + fun onUsbDeviceAttached() = hwWalletRepo.onTransportRestored(TransportType.USB) fun submitPairingCode(code: String) = hwWalletRepo.submitPairingCode(code) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index f9c62d578e..ac364b1952 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -332,9 +332,9 @@ class HwWalletRepoTest : BaseUnitTest() { fun `forwards transport restored to the trezor repo`() = test { val sut = createRepo() - sut.onTransportRestored() + sut.onTransportRestored(TransportType.USB) - verify(trezorRepo).onTransportRestored() + verify(trezorRepo).onTransportRestored(TransportType.USB) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 88d1814edc..65b6797ad3 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -41,6 +41,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) +@Suppress("LargeClass") class TrezorRepoTest : BaseUnitTest() { companion object Fixtures { @@ -120,17 +121,19 @@ class TrezorRepoTest : BaseUnitTest() { on { this.model }.thenReturn(model) } + @Suppress("LongParameterList") private fun mockKnownDevice( id: String = DEVICE_ID, name: String? = DEVICE_NAME, path: String = DEVICE_PATH, label: String? = DEVICE_LABEL, model: String? = DEVICE_MODEL, + transportType: TransportType = TransportType.USB, ) = KnownDevice( id = id, name = name, path = path, - transportType = TransportType.USB, + transportType = transportType, label = label, model = model, lastConnectedAt = 123L, @@ -214,7 +217,7 @@ class TrezorRepoTest : BaseUnitTest() { @Test fun `transport restored auto-reconnects to a known device`() = test { - val transportRestored = MutableSharedFlow() + val transportRestored = MutableSharedFlow() val features = mockFeatures() val device = mockDeviceInfo() whenever(trezorTransport.transportRestored).thenReturn(transportRestored) @@ -224,7 +227,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() - transportRestored.emit(Unit) + transportRestored.emit(TransportType.USB) advanceUntilIdle() assertNotNull(sut.state.value.connected) @@ -232,7 +235,7 @@ class TrezorRepoTest : BaseUnitTest() { @Test fun `transport restored retries reconnect until the device is discoverable`() = test { - val transportRestored = MutableSharedFlow() + val transportRestored = MutableSharedFlow() val features = mockFeatures() val device = mockDeviceInfo() whenever(trezorTransport.transportRestored).thenReturn(transportRestored) @@ -243,13 +246,36 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() - transportRestored.emit(Unit) + transportRestored.emit(TransportType.USB) advanceUntilIdle() assertNotNull(sut.state.value.connected) verify(trezorService, times(2)).scan() } + @Test + fun `reconnect prefers the transport that came back`() = test { + val features = mockFeatures() + val bleDevice = mockDeviceInfo(id = "ble-1", transportType = TrezorTransportType.BLUETOOTH, path = "ble-path") + val usbDevice = mockDeviceInfo(id = "usb-1", transportType = TrezorTransportType.USB, path = "usb-path") + whenever(hwWalletStore.loadKnownDevices()).thenReturn( + listOf( + mockKnownDevice(id = "ble-1", transportType = TransportType.BLUETOOTH), + mockKnownDevice(id = "usb-1"), + ), + ) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(bleDevice, usbDevice)) + whenever(trezorService.connect(eq("usb-1"), any())).thenReturn(features) + sut = createSut() + + sut.onTransportRestored(TransportType.USB) + advanceUntilIdle() + + verify(trezorService).connect(eq("usb-1"), any()) + verify(trezorService, never()).connect(eq("ble-1"), any()) + } + @Test fun `repeated transport restored triggers run a single reconnect`() = test { val features = mockFeatures() @@ -260,7 +286,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() - repeat(3) { sut.onTransportRestored() } + repeat(3) { sut.onTransportRestored(TransportType.USB) } advanceUntilIdle() assertNotNull(sut.state.value.connected) @@ -283,13 +309,13 @@ class TrezorRepoTest : BaseUnitTest() { @Test fun `transport restored skips reconnect while device awaits pairing code`() = test { - val transportRestored = MutableSharedFlow() + val transportRestored = MutableSharedFlow() whenever(trezorTransport.transportRestored).thenReturn(transportRestored) whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(true)) whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) sut = createSut() - transportRestored.emit(Unit) + transportRestored.emit(TransportType.USB) advanceUntilIdle() verify(trezorService, never()).disconnect() @@ -306,7 +332,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) sut = createSut() - sut.onTransportRestored() + sut.onTransportRestored(TransportType.USB) advanceUntilIdle() assertNotNull(sut.state.value.connected) @@ -333,7 +359,7 @@ class TrezorRepoTest : BaseUnitTest() { @Test fun `transport restored does not reconnect when a device is already connected`() = test { - val transportRestored = MutableSharedFlow() + val transportRestored = MutableSharedFlow() val features = mockFeatures() val device = mockDeviceInfo() whenever(trezorTransport.transportRestored).thenReturn(transportRestored) @@ -343,7 +369,7 @@ class TrezorRepoTest : BaseUnitTest() { sut.scan() sut.connect(DEVICE_ID) - transportRestored.emit(Unit) + transportRestored.emit(TransportType.USB) advanceUntilIdle() verify(trezorService, times(1)).scan() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 9e3a4ccaca..48b151aeae 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -42,6 +42,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransportType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -255,7 +256,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test { sut.onUsbDeviceAttached() - verify(hwWalletRepo).onTransportRestored() + verify(hwWalletRepo).onTransportRestored(TransportType.USB) } @Test From b301a77b52f37f4b581213ec4bea4f9b4e1a3ede Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 10 Jun 2026 10:26:15 +0200 Subject: [PATCH 37/37] chore: fix import ordering --- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt | 2 +- .../main/java/to/bitkit/ui/settings/support/SupportScreen.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 79c46dbfa4..b294583796 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -177,12 +177,12 @@ import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet -import to.bitkit.ui.sheets.HardwareSheet import to.bitkit.ui.sheets.ChangePinSheet import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet import to.bitkit.ui.sheets.ForceTransferSheet import to.bitkit.ui.sheets.GiftSheet +import to.bitkit.ui.sheets.HardwareSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet 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 58f04796a1..69374e6e4c 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 @@ -116,11 +116,11 @@ import to.bitkit.ext.rawId import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem -import to.bitkit.models.TransportType import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion import to.bitkit.models.Toast +import to.bitkit.models.TransportType import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition diff --git a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt index 2813848587..1fdb18c5ac 100644 --- a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import java.time.LocalDate import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.env.Env @@ -65,6 +64,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import java.time.LocalDate private const val DEV_MODE_TAP_THRESHOLD = 5 private const val COPYRIGHT_YEAR_PLACEHOLDER = "{year}"