From b4fd96a3041b2be73c3962e56e727b18555ee823 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 29 May 2026 09:16:23 -0400 Subject: [PATCH 01/10] feat: add trezor onchain event watcher --- .../java/to/bitkit/repositories/TrezorRepo.kt | 57 ++++ .../java/to/bitkit/services/TrezorService.kt | 19 ++ .../ui/screens/trezor/TrezorPreviewData.kt | 20 ++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 26 +- .../ui/screens/trezor/TrezorViewModel.kt | 222 +++++++++++++++ .../ui/screens/trezor/WatcherSection.kt | 263 ++++++++++++++++++ .../ui/screens/trezor/TrezorViewModelTest.kt | 4 + gradle/libs.versions.toml | 2 +- 8 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index f12afe2293..18adba263f 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.ComposeResult +import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorAddressResponse @@ -22,6 +23,8 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.WalletParams import com.synonym.bitkitcore.WalletSelection +import com.synonym.bitkitcore.WatcherEvent +import com.synonym.bitkitcore.WatcherParams import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,7 +32,10 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -73,6 +79,16 @@ class TrezorRepo @Inject constructor( private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() + private val _watcherEvents = MutableSharedFlow>(extraBufferCapacity = 64) + val watcherEvents: SharedFlow> = _watcherEvents.asSharedFlow() + + private val eventBridge: EventListener = object : EventListener { + override fun onEvent(watcherId: String, event: WatcherEvent) { + TrezorDebugLog.log("WATCHER", "[$watcherId] ${event::class.simpleName}") + _watcherEvents.tryEmit(watcherId to event) + } + } + /** * Flow indicating when a pairing code needs to be entered. * UI should show a dialog when this emits true. @@ -551,6 +567,47 @@ class TrezorRepo @Inject constructor( } } + suspend fun startWatcher( + watcherId: String, + extendedKey: String, + network: BitkitCoreNetwork, + gapLimit: UInt = 20u, + ): Result = withContext(ioDispatcher) { + runCatching { + val params = WatcherParams( + watcherId = watcherId, + extendedKey = extendedKey, + electrumUrl = electrumUrlForNetwork(network), + network = network, + accountType = null, + gapLimit = gapLimit, + ) + trezorService.startWatcher(params, eventBridge) + TrezorDebugLog.log("WATCHER", "Started watcher '$watcherId' for '${extendedKey.take(12)}...'") + Logger.info("Started watcher '$watcherId'", context = TAG) + }.onFailure { + Logger.error("Start watcher failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + } + + fun stopWatcher(watcherId: String): Result = runCatching { + trezorService.stopWatcher(watcherId) + TrezorDebugLog.log("WATCHER", "Stopped watcher '$watcherId'") + Logger.info("Stopped watcher '$watcherId'", context = TAG) + }.onFailure { + Logger.error("Stop watcher failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + fun stopAllWatchers(): Result = runCatching { + trezorService.stopAllWatchers() + TrezorDebugLog.log("WATCHER", "Stopped all watchers") + }.onFailure { + Logger.error("Stop all watchers failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + fun clearError() { _state.update { it.copy(error = null) } } diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index ee68e241e8..ca7dac691e 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.ComposeResult +import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorAddressResponse @@ -19,11 +20,15 @@ import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorVerifyMessageParams import com.synonym.bitkitcore.WalletSelection +import com.synonym.bitkitcore.WatcherParams import com.synonym.bitkitcore.onchainBroadcastRawTx import com.synonym.bitkitcore.onchainComposeTransaction import com.synonym.bitkitcore.onchainGetAccountInfo import com.synonym.bitkitcore.onchainGetAddressInfo import com.synonym.bitkitcore.onchainGetTransactionHistory +import com.synonym.bitkitcore.onchainStartWatcher +import com.synonym.bitkitcore.onchainStopAllWatchers +import com.synonym.bitkitcore.onchainStopWatcher import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect @@ -266,4 +271,18 @@ class TrezorService @Inject constructor( ) } } + + suspend fun startWatcher(params: WatcherParams, listener: EventListener) { + ServiceQueue.CORE.background { + onchainStartWatcher(params = params, listener = listener) + } + } + + fun stopWatcher(watcherId: String) { + onchainStopWatcher(watcherId = watcherId) + } + + fun stopAllWatchers() { + onchainStopAllWatchers() + } } 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 e78ad9942c..2842e42005 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 @@ -17,6 +17,8 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import to.bitkit.repositories.ConnectedTrezorDevice import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.KnownDeviceTransportType @@ -305,6 +307,24 @@ internal object TrezorPreviewData { ), ) + val uiStateWithActiveWatcher = TrezorUiState( + network = TrezorNetworkState(selectedNetwork = BitkitCoreNetwork.REGTEST), + watcher = TrezorWatcherState( + extendedKey = SAMPLE_XPUB, + activeWatcherId = "watcher-abc-123", + connectionStatus = WatcherConnectionStatus.CONNECTED, + balance = sampleWalletBalance, + transactions = sampleHistoryTransactions.toImmutableList(), + transactionCount = 2u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + events = persistentListOf( + "Watcher started: watcher-abc-123", + "TX update: 2 txs, balance=155000 sats", + ), + ), + ) + val uiStateBroadcast = TrezorUiState( network = TrezorNetworkState(selectedNetwork = BitkitCoreNetwork.REGTEST), send = TrezorSendState( 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 5249442fb4..7a8728cf40 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 @@ -167,6 +167,11 @@ private fun TrezorScreenContent( onResetSend = viewModel::resetSendFlow, onTxHistoryInputChange = viewModel::setTxHistoryInput, onLookupTxHistory = viewModel::lookupTransactionHistory, + onWatcherExtendedKeyChange = viewModel::setWatcherExtendedKey, + onWatcherGapLimitChange = viewModel::setWatcherGapLimit, + onStartWatcher = viewModel::startWatcher, + onStopWatcher = viewModel::stopWatcher, + onPopulateWatcherFromXpub = viewModel::populateWatcherFromXpub, permissionsGranted = permissionsState.allPermissionsGranted, ) } @@ -207,6 +212,11 @@ private fun Content( onResetSend: () -> Unit = {}, onTxHistoryInputChange: (String) -> Unit = {}, onLookupTxHistory: () -> Unit = {}, + onWatcherExtendedKeyChange: (String) -> Unit = {}, + onWatcherGapLimitChange: (String) -> Unit = {}, + onStartWatcher: () -> Unit = {}, + onStopWatcher: () -> Unit = {}, + onPopulateWatcherFromXpub: () -> Unit = {}, permissionsGranted: Boolean = true, ) { Column( @@ -419,7 +429,7 @@ private fun Content( onResetSend = onResetSend, ) - // Transaction History (always visible, no device needed) + // Transaction History (one-shot snapshot, no device needed) VerticalSpacer(32.dp) TransactionHistorySection( uiState = uiState, @@ -427,6 +437,18 @@ private fun Content( onLookup = onLookupTxHistory, ) + // Event Watcher (live subscription, no device needed) + VerticalSpacer(32.dp) + WatcherSection( + uiState = uiState, + trezorState = trezorState, + onExtendedKeyChange = onWatcherExtendedKeyChange, + onGapLimitChange = onWatcherGapLimitChange, + onStartWatcher = onStartWatcher, + onStopWatcher = onStopWatcher, + onPopulateFromXpub = onPopulateWatcherFromXpub, + ) + // Debug Log Window DebugLogSection() } @@ -668,7 +690,7 @@ private fun StatusRow(trezorState: TrezorState) { } @Composable -private fun StatusBadge(text: String, color: Color) { +internal fun StatusBadge(text: String, color: Color) { Caption( text = text, color = color, 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 f6021b2f8e..c1ecab7fdf 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 @@ -5,14 +5,21 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult +import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.WalletBalance +import com.synonym.bitkitcore.WatcherEvent import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -30,6 +37,7 @@ import to.bitkit.repositories.TrezorRepo import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorWalletMode import to.bitkit.ui.shared.toast.ToastEventBus +import java.util.UUID import javax.inject.Inject import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -42,6 +50,66 @@ class TrezorViewModel @Inject constructor( init { trezorRepo.observeExternalDisconnects(viewModelScope) + observeWatcherEvents() + } + + private fun observeWatcherEvents() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.watcherEvents.collect { (watcherId, event) -> + if (watcherId != _uiState.value.activeWatcherId) return@collect + when (event) { + is WatcherEvent.TransactionsChanged -> _uiState.update { + it.copy( + watcher = it.watcher.copy( + balance = event.balance, + transactions = event.transactions.toImmutableList(), + transactionCount = event.txCount, + blockHeight = event.blockHeight, + accountType = event.accountType, + connectionStatus = WatcherConnectionStatus.CONNECTED, + events = ( + it.watcher.events + + "TX update: ${event.txCount} txs, balance=${event.balance.total} sats" + ).takeLast(MAX_WATCHER_EVENT_LOG).toImmutableList(), + ) + ) + } + + is WatcherEvent.Error -> { + _uiState.update { + it.copy( + watcher = it.watcher.copy( + connectionStatus = WatcherConnectionStatus.ERROR, + events = (it.watcher.events + "Error: ${event.message}") + .takeLast(MAX_WATCHER_EVENT_LOG).toImmutableList(), + ) + ) + } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Watcher error: ${event.message}") + } + + is WatcherEvent.Disconnected -> _uiState.update { + it.copy( + watcher = it.watcher.copy( + connectionStatus = WatcherConnectionStatus.DISCONNECTED, + events = (it.watcher.events + "Disconnected: ${event.message}") + .takeLast(MAX_WATCHER_EVENT_LOG).toImmutableList(), + ) + ) + } + + is WatcherEvent.Reconnected -> _uiState.update { + it.copy( + watcher = it.watcher.copy( + connectionStatus = WatcherConnectionStatus.CONNECTED, + events = (it.watcher.events + "Reconnected") + .takeLast(MAX_WATCHER_EVENT_LOG).toImmutableList(), + ) + ) + } + } + } + } } val trezorState = trezorRepo.state @@ -602,6 +670,107 @@ class TrezorViewModel @Inject constructor( } } + fun setWatcherExtendedKey(key: String) { + _uiState.update { it.copy(watcher = it.watcher.copy(extendedKey = key)) } + } + + fun setWatcherGapLimit(limit: String) { + _uiState.update { it.copy(watcher = it.watcher.copy(gapLimit = limit)) } + } + + fun populateWatcherFromXpub() { + val xpub = trezorRepo.state.value.lastPublicKey?.xpub ?: return + _uiState.update { it.copy(watcher = it.watcher.copy(extendedKey = xpub)) } + } + + fun startWatcher() { + viewModelScope.launch(bgDispatcher) { + val state = _uiState.value + val key = state.watcherExtendedKey.trim() + if (key.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter an extended key (xpub)") + return@launch + } + val gapLimit = state.watcherGapLimit.toUIntOrNull() + if (gapLimit == null) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Gap limit must be a positive integer") + return@launch + } + + val watcherId = UUID.randomUUID().toString() + _uiState.update { + it.copy( + watcher = it.watcher.copy( + isStarting = true, + activeWatcherId = watcherId, + connectionStatus = WatcherConnectionStatus.STARTING, + events = persistentListOf("Watcher starting: $watcherId"), + ) + ) + } + trezorRepo.startWatcher( + watcherId = watcherId, + extendedKey = key, + network = state.selectedNetwork, + gapLimit = gapLimit, + ) + .onSuccess { + _uiState.update { + it.copy( + watcher = it.watcher.copy( + isStarting = false, + connectionStatus = WatcherConnectionStatus.CONNECTED, + ) + ) + } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Watcher started") + } + .onFailure { + _uiState.update { + it.copy( + watcher = it.watcher.copy( + isStarting = false, + activeWatcherId = null, + connectionStatus = WatcherConnectionStatus.IDLE, + events = persistentListOf(), + ) + ) + } + ToastEventBus.send(it) + } + } + } + + fun stopWatcher() { + val watcherId = _uiState.value.activeWatcherId ?: return + trezorRepo.stopWatcher(watcherId) + .onSuccess { + _uiState.update { + it.copy( + watcher = it.watcher.copy( + activeWatcherId = null, + connectionStatus = WatcherConnectionStatus.IDLE, + balance = null, + transactions = persistentListOf(), + transactionCount = 0u, + blockHeight = 0u, + accountType = null, + events = persistentListOf(), + ) + ) + } + viewModelScope.launch { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Watcher stopped") + } + } + .onFailure { viewModelScope.launch { ToastEventBus.send(it) } } + } + + override fun onCleared() { + _uiState.value.activeWatcherId?.let { trezorRepo.stopWatcher(it) } + super.onCleared() + } + fun clearError() { trezorRepo.clearError() } @@ -660,6 +829,7 @@ data class TrezorUiState( val lookup: TrezorLookupState = TrezorLookupState(), val send: TrezorSendState = TrezorSendState(), val txHistory: TrezorTxHistoryState = TrezorTxHistoryState(), + val watcher: TrezorWatcherState = TrezorWatcherState(), ) { val selectedNetwork: BitkitCoreNetwork get() = network.selectedNetwork @@ -747,6 +917,39 @@ data class TrezorUiState( val txHistoryResult: TransactionHistoryResult? get() = txHistory.result + + val watcherExtendedKey: String + get() = watcher.extendedKey + + val watcherGapLimit: String + get() = watcher.gapLimit + + val isStartingWatcher: Boolean + get() = watcher.isStarting + + val activeWatcherId: String? + get() = watcher.activeWatcherId + + val watcherConnectionStatus: WatcherConnectionStatus + get() = watcher.connectionStatus + + val watcherBalance: WalletBalance? + get() = watcher.balance + + val watcherTransactions: ImmutableList + get() = watcher.transactions + + val watcherTransactionCount: UInt + get() = watcher.transactionCount + + val watcherBlockHeight: UInt + get() = watcher.blockHeight + + val watcherAccountType: AccountType? + get() = watcher.accountType + + val watcherEvents: ImmutableList + get() = watcher.events } @Stable @@ -798,6 +1001,25 @@ data class TrezorTxHistoryState( val result: TransactionHistoryResult? = null, ) +@Stable +data class TrezorWatcherState( + val extendedKey: String = "", + val gapLimit: String = "20", + val isStarting: Boolean = false, + val activeWatcherId: String? = null, + val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, + val balance: WalletBalance? = null, + val transactions: ImmutableList = persistentListOf(), + val transactionCount: UInt = 0u, + val blockHeight: UInt = 0u, + val accountType: AccountType? = null, + val events: ImmutableList = persistentListOf(), +) + +private const val MAX_WATCHER_EVENT_LOG = 50 + +enum class WatcherConnectionStatus { IDLE, STARTING, CONNECTED, DISCONNECTED, ERROR } + sealed interface SendStep { data object Form : SendStep diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt new file mode 100644 index 0000000000..2af8dd54f2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -0,0 +1,263 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.TxDirection +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Suppress("LongParameterList") +@Composable +internal fun WatcherSection( + uiState: TrezorUiState, + trezorState: TrezorState, + onExtendedKeyChange: (String) -> Unit, + onGapLimitChange: (String) -> Unit, + onStartWatcher: () -> Unit, + onStopWatcher: () -> Unit, + onPopulateFromXpub: () -> Unit, +) { + Column { + Caption13Up( + text = "Event Watcher", + color = Colors.White64, + ) + VerticalSpacer(8.dp) + + OutlinedTextField( + value = uiState.watcherExtendedKey, + onValueChange = onExtendedKeyChange, + label = { Caption("Extended key (xpub/tpub/...)", color = Colors.White50) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + VerticalSpacer(8.dp) + + AnimatedVisibility(visible = trezorState.lastPublicKey != null) { + Column { + SecondaryButton( + text = "Use xpub from device", + onClick = onPopulateFromXpub, + size = ButtonSize.Small, + ) + VerticalSpacer(8.dp) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = uiState.watcherGapLimit, + onValueChange = onGapLimitChange, + label = { Caption("Gap limit", color = Colors.White50) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + maxLines = 1, + modifier = Modifier.weight(1f), + ) + } + + VerticalSpacer(16.dp) + + if (uiState.activeWatcherId != null) { + SecondaryButton( + text = "Stop Watching", + onClick = onStopWatcher, + size = ButtonSize.Small, + ) + } else { + PrimaryButton( + text = if (uiState.isStartingWatcher) "Starting..." else "Start Watching", + onClick = onStartWatcher, + enabled = !uiState.isStartingWatcher && uiState.watcherExtendedKey.isNotBlank(), + size = ButtonSize.Small, + ) + } + + AnimatedVisibility( + visible = uiState.activeWatcherId != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + VerticalSpacer(16.dp) + WatcherStatusContent(uiState) + } + } + } +} + +private fun WatcherConnectionStatus.toColor(): Color = when (this) { + WatcherConnectionStatus.IDLE -> Colors.White50 + WatcherConnectionStatus.STARTING -> Colors.Yellow + WatcherConnectionStatus.CONNECTED -> Colors.Green + WatcherConnectionStatus.DISCONNECTED -> Colors.Yellow + WatcherConnectionStatus.ERROR -> Colors.Red +} + +@Composable +private fun WatcherStatusContent(uiState: TrezorUiState) { + StatusBadge( + text = uiState.watcherConnectionStatus.name, + color = uiState.watcherConnectionStatus.toColor(), + ) + + uiState.watcherBalance?.let { balance -> + VerticalSpacer(12.dp) + ResultCard { + InfoRow("Confirmed", "${balance.confirmed} sats") + InfoRow("Pending", "${balance.trustedPending + balance.untrustedPending} sats") + InfoRow("Total", "${balance.total} sats") + InfoRow("Block Height", "${uiState.watcherBlockHeight}") + InfoRow("Account Type", uiState.watcherAccountType?.name ?: "-") + InfoRow("Transactions", "${uiState.watcherTransactionCount}") + } + } + + if (uiState.watcherTransactions.isNotEmpty()) { + VerticalSpacer(12.dp) + Caption13Up( + text = "Transactions (${uiState.watcherTransactions.size})", + color = Colors.White64, + ) + VerticalSpacer(4.dp) + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(uiState.watcherTransactions) { tx -> + val directionLabel = when (tx.direction) { + TxDirection.SENT -> "Sent" + TxDirection.RECEIVED -> "Recv" + TxDirection.SELF_TRANSFER -> "Self" + } + val directionColor = when (tx.direction) { + TxDirection.SENT -> Colors.Red + TxDirection.RECEIVED -> Colors.Green + TxDirection.SELF_TRANSFER -> Colors.White64 + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Caption( + text = "$directionLabel ${tx.amount} sats", + color = directionColor, + ) + HorizontalSpacer(8.dp) + Caption( + text = "${tx.txid.take(8)}...${tx.txid.takeLast(8)}", + color = Colors.White50, + ) + HorizontalSpacer(8.dp) + Caption( + text = "${tx.confirmations} conf", + color = Colors.White50, + ) + } + } + } + } + + if (uiState.watcherEvents.isNotEmpty()) { + VerticalSpacer(12.dp) + Caption13Up( + text = "Event Log", + color = Colors.White64, + ) + VerticalSpacer(4.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 150.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Black.copy(alpha = 0.5f)) + .padding(8.dp), + ) { + uiState.watcherEvents.forEach { event -> + Footnote( + text = event, + color = Colors.White80, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewWatcherEmpty() { + AppThemeSurface { + WatcherSection( + uiState = TrezorUiState(), + trezorState = TrezorState(), + onExtendedKeyChange = {}, + onGapLimitChange = {}, + onStartWatcher = {}, + onStopWatcher = {}, + onPopulateFromXpub = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewWatcherActive() { + AppThemeSurface { + WatcherSection( + uiState = TrezorPreviewData.uiStateWithActiveWatcher, + trezorState = TrezorState(), + onExtendedKeyChange = {}, + onGapLimitChange = {}, + onStartWatcher = {}, + onStopWatcher = {}, + onPopulateFromXpub = {}, + ) + } +} 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 b151e6f186..8ebea41c7d 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 @@ -1,8 +1,10 @@ package to.bitkit.ui.screens.trezor import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -34,6 +36,7 @@ class TrezorViewModelTest : BaseUnitTest() { private val needsPairingCodeFlow = MutableStateFlow(false) private val needsPinEntryFlow = MutableStateFlow(false) private val walletModeFlow = MutableStateFlow(TrezorWalletMode.STANDARD) + private val watcherEventsFlow = MutableSharedFlow>() private lateinit var sut: TrezorViewModel @@ -43,6 +46,7 @@ class TrezorViewModelTest : BaseUnitTest() { whenever(trezorRepo.needsPairingCode).thenReturn(needsPairingCodeFlow) whenever(trezorRepo.needsPinEntry).thenReturn(needsPinEntryFlow) whenever(trezorRepo.walletMode).thenReturn(walletModeFlow) + whenever(trezorRepo.watcherEvents).thenReturn(watcherEventsFlow) whenever(trezorRepo.observeExternalDisconnects(any())).then { } sut = createViewModel() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f01b2d0183..e4aa48dd8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.62" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.65" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From c11b59b302146e3cf69e95893b60a22033d2b127 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 29 May 2026 09:43:41 -0400 Subject: [PATCH 02/10] fix: disable stop watching while starting --- app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index 2af8dd54f2..275e269a15 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -108,6 +108,7 @@ internal fun WatcherSection( SecondaryButton( text = "Stop Watching", onClick = onStopWatcher, + enabled = !uiState.isStartingWatcher, size = ButtonSize.Small, ) } else { From 7b55d4ad284b6f0c093d07ddafa3c678dc7ce655 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 29 May 2026 14:01:58 -0400 Subject: [PATCH 03/10] chore: bump bitkit-core to 0.1.66 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4aa48dd8e..9ca7f233ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.65" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.66" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From 869586b39bd0a284d1018b8210bfdd0c667b13d6 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 2 Jun 2026 11:14:38 -0400 Subject: [PATCH 04/10] refactor: extract trezor watcher log tag const --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 18adba263f..75b40df91e 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -71,6 +71,7 @@ class TrezorRepo @Inject constructor( ) { companion object { private const val TAG = "TrezorRepo" + private const val WATCHER_TAG = "WATCHER" 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 @@ -84,7 +85,7 @@ class TrezorRepo @Inject constructor( private val eventBridge: EventListener = object : EventListener { override fun onEvent(watcherId: String, event: WatcherEvent) { - TrezorDebugLog.log("WATCHER", "[$watcherId] ${event::class.simpleName}") + TrezorDebugLog.log(WATCHER_TAG, "[$watcherId] ${event::class.simpleName}") _watcherEvents.tryEmit(watcherId to event) } } @@ -583,7 +584,7 @@ class TrezorRepo @Inject constructor( gapLimit = gapLimit, ) trezorService.startWatcher(params, eventBridge) - TrezorDebugLog.log("WATCHER", "Started watcher '$watcherId' for '${extendedKey.take(12)}...'") + TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId' for '${extendedKey.take(12)}...'") Logger.info("Started watcher '$watcherId'", context = TAG) }.onFailure { Logger.error("Start watcher failed", it, context = TAG) @@ -593,7 +594,7 @@ class TrezorRepo @Inject constructor( fun stopWatcher(watcherId: String): Result = runCatching { trezorService.stopWatcher(watcherId) - TrezorDebugLog.log("WATCHER", "Stopped watcher '$watcherId'") + TrezorDebugLog.log(WATCHER_TAG, "Stopped watcher '$watcherId'") Logger.info("Stopped watcher '$watcherId'", context = TAG) }.onFailure { Logger.error("Stop watcher failed", it, context = TAG) @@ -602,7 +603,7 @@ class TrezorRepo @Inject constructor( fun stopAllWatchers(): Result = runCatching { trezorService.stopAllWatchers() - TrezorDebugLog.log("WATCHER", "Stopped all watchers") + TrezorDebugLog.log(WATCHER_TAG, "Stopped all watchers") }.onFailure { Logger.error("Stop all watchers failed", it, context = TAG) _state.update { s -> s.copy(error = it.message) } From 21a4f919d1b8735f2b118df9621c0b0653f57f44 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 3 Jun 2026 13:01:01 +0200 Subject: [PATCH 05/10] fix: harden watcher lifecycle --- .../java/to/bitkit/repositories/TrezorRepo.kt | 38 ++++++---- .../java/to/bitkit/services/TrezorService.kt | 12 ++- .../ui/screens/trezor/TrezorViewModel.kt | 41 +++++----- .../ui/screens/trezor/WatcherSection.kt | 8 +- .../ui/screens/trezor/TrezorViewModelTest.kt | 75 +++++++++++++++++++ 5 files changed, 133 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 75b40df91e..61f2208875 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.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -40,6 +41,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn 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 @@ -80,6 +82,8 @@ class TrezorRepo @Inject constructor( private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() + private val watcherCleanupScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val _watcherEvents = MutableSharedFlow>(extraBufferCapacity = 64) val watcherEvents: SharedFlow> = _watcherEvents.asSharedFlow() @@ -592,21 +596,29 @@ class TrezorRepo @Inject constructor( } } - fun stopWatcher(watcherId: String): Result = runCatching { - trezorService.stopWatcher(watcherId) - TrezorDebugLog.log(WATCHER_TAG, "Stopped watcher '$watcherId'") - Logger.info("Stopped watcher '$watcherId'", context = TAG) - }.onFailure { - Logger.error("Stop watcher failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + suspend fun stopWatcher(watcherId: String): Result = withContext(ioDispatcher) { + runCatching { + trezorService.stopWatcher(watcherId) + TrezorDebugLog.log(WATCHER_TAG, "Stopped watcher '$watcherId'") + Logger.info("Stopped watcher '$watcherId'", context = TAG) + }.onFailure { + Logger.error("Stop watcher failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } - fun stopAllWatchers(): Result = runCatching { - trezorService.stopAllWatchers() - TrezorDebugLog.log(WATCHER_TAG, "Stopped all watchers") - }.onFailure { - Logger.error("Stop all watchers failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + fun stopWatcherOnCleared(watcherId: String) { + watcherCleanupScope.launch { stopWatcher(watcherId) } + } + + suspend fun stopAllWatchers(): Result = withContext(ioDispatcher) { + runCatching { + trezorService.stopAllWatchers() + TrezorDebugLog.log(WATCHER_TAG, "Stopped all watchers") + }.onFailure { + Logger.error("Stop all watchers failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } fun clearError() { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index ca7dac691e..195b87728d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -278,11 +278,15 @@ class TrezorService @Inject constructor( } } - fun stopWatcher(watcherId: String) { - onchainStopWatcher(watcherId = watcherId) + suspend fun stopWatcher(watcherId: String) { + ServiceQueue.CORE.background { + onchainStopWatcher(watcherId = watcherId) + } } - fun stopAllWatchers() { - onchainStopAllWatchers() + suspend fun stopAllWatchers() { + ServiceQueue.CORE.background { + onchainStopAllWatchers() + } } } 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 c1ecab7fdf..c9f317beef 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 @@ -692,7 +692,7 @@ class TrezorViewModel @Inject constructor( return@launch } val gapLimit = state.watcherGapLimit.toUIntOrNull() - if (gapLimit == null) { + if (gapLimit == null || gapLimit == 0u) { ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Gap limit must be a positive integer") return@launch } @@ -719,7 +719,6 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( isStarting = false, - connectionStatus = WatcherConnectionStatus.CONNECTED, ) ) } @@ -743,31 +742,31 @@ class TrezorViewModel @Inject constructor( fun stopWatcher() { val watcherId = _uiState.value.activeWatcherId ?: return - trezorRepo.stopWatcher(watcherId) - .onSuccess { - _uiState.update { - it.copy( - watcher = it.watcher.copy( - activeWatcherId = null, - connectionStatus = WatcherConnectionStatus.IDLE, - balance = null, - transactions = persistentListOf(), - transactionCount = 0u, - blockHeight = 0u, - accountType = null, - events = persistentListOf(), + viewModelScope.launch(bgDispatcher) { + trezorRepo.stopWatcher(watcherId) + .onSuccess { + _uiState.update { + it.copy( + watcher = it.watcher.copy( + activeWatcherId = null, + connectionStatus = WatcherConnectionStatus.IDLE, + balance = null, + transactions = persistentListOf(), + transactionCount = 0u, + blockHeight = 0u, + accountType = null, + events = persistentListOf(), + ) ) - ) - } - viewModelScope.launch { + } ToastEventBus.send(type = Toast.ToastType.INFO, title = "Watcher stopped") } - } - .onFailure { viewModelScope.launch { ToastEventBus.send(it) } } + .onFailure { ToastEventBus.send(it) } + } } override fun onCleared() { - _uiState.value.activeWatcherId?.let { trezorRepo.stopWatcher(it) } + _uiState.value.activeWatcherId?.let { trezorRepo.stopWatcherOnCleared(it) } super.onCleared() } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index 275e269a15..8c07046b6d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TxDirection +import to.bitkit.models.safe import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption @@ -151,8 +152,9 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { uiState.watcherBalance?.let { balance -> VerticalSpacer(12.dp) ResultCard { + val pending = balance.trustedPending.safe() + balance.untrustedPending.safe() InfoRow("Confirmed", "${balance.confirmed} sats") - InfoRow("Pending", "${balance.trustedPending + balance.untrustedPending} sats") + InfoRow("Pending", "$pending sats") InfoRow("Total", "${balance.total} sats") InfoRow("Block Height", "${uiState.watcherBlockHeight}") InfoRow("Account Type", uiState.watcherAccountType?.name ?: "-") @@ -213,7 +215,7 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { color = Colors.White64, ) VerticalSpacer(4.dp) - Column( + LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 150.dp) @@ -221,7 +223,7 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { .background(Colors.Black.copy(alpha = 0.5f)) .padding(8.dp), ) { - uiState.watcherEvents.forEach { event -> + items(uiState.watcherEvents.asReversed()) { event -> Footnote( text = event, color = Colors.White80, 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 8ebea41c7d..e85006a9c3 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 @@ -24,6 +24,7 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -370,6 +371,80 @@ class TrezorViewModelTest : BaseUnitTest() { verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) } + @Test + fun `startWatcher should keep status starting until watcher event arrives`() = test { + whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + .thenReturn(Result.success(Unit)) + sut.setWatcherExtendedKey("xpub6test123") + + sut.startWatcher() + advanceUntilIdle() + + val state = sut.uiState.value + assertFalse(state.isStartingWatcher) + assertNotNull(state.activeWatcherId) + assertEquals(WatcherConnectionStatus.STARTING, state.watcherConnectionStatus) + } + + @Test + fun `startWatcher should reject zero gap limit`() = test { + sut.setWatcherExtendedKey("xpub6test123") + sut.setWatcherGapLimit("0") + + sut.startWatcher() + advanceUntilIdle() + + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any()) + assertNull(sut.uiState.value.activeWatcherId) + } + + @Test + fun `watcher transaction event should mark watcher connected`() = test { + whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + .thenReturn(Result.success(Unit)) + sut.setWatcherExtendedKey("xpub6test123") + sut.startWatcher() + advanceUntilIdle() + val watcherId = assertNotNull(sut.uiState.value.activeWatcherId) + + watcherEventsFlow.emit( + watcherId to WatcherEvent.TransactionsChanged( + transactions = TrezorPreviewData.sampleHistoryTransactions, + balance = TrezorPreviewData.sampleWalletBalance, + txCount = 3u, + blockHeight = 850_000u, + accountType = TrezorPreviewData.sampleTransactionHistoryResult.accountType, + ), + ) + advanceUntilIdle() + + val state = sut.uiState.value + assertEquals(WatcherConnectionStatus.CONNECTED, state.watcherConnectionStatus) + assertEquals(TrezorPreviewData.sampleWalletBalance, state.watcherBalance) + assertEquals(3u, state.watcherTransactionCount) + } + + @Test + fun `stopWatcher should stop repo watcher and clear watcher state`() = test { + whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + .thenReturn(Result.success(Unit)) + whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) + sut.setWatcherExtendedKey("xpub6test123") + sut.startWatcher() + advanceUntilIdle() + val watcherId = assertNotNull(sut.uiState.value.activeWatcherId) + + sut.stopWatcher() + advanceUntilIdle() + + verify(trezorRepo).stopWatcher(watcherId) + val state = sut.uiState.value + assertNull(state.activeWatcherId) + assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) + assertNull(state.watcherBalance) + assertTrue(state.watcherTransactions.isEmpty()) + } + @Test fun `clearError should call trezorRepo clearError`() { sut.clearError() From 58f11e5f04d1a6d94a26d836dd8d6fe648e46fd4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 3 Jun 2026 20:32:50 +0200 Subject: [PATCH 06/10] fix: guard watcher startup cleanup --- .../ui/screens/trezor/TrezorViewModel.kt | 87 +++++++++++++------ .../ui/screens/trezor/WatcherSection.kt | 2 +- .../ui/screens/trezor/TrezorViewModelTest.kt | 57 ++++++++++-- 3 files changed, 112 insertions(+), 34 deletions(-) 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 c9f317beef..2254b3e630 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 @@ -21,6 +21,8 @@ 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.asStateFlow @@ -48,6 +50,11 @@ class TrezorViewModel @Inject constructor( private val trezorRepo: TrezorRepo, ) : ViewModel() { + @Volatile + private var isCleared = false + + private val watcherStartScope = CoroutineScope(SupervisorJob() + bgDispatcher) + init { trezorRepo.observeExternalDisconnects(viewModelScope) observeWatcherEvents() @@ -56,7 +63,7 @@ class TrezorViewModel @Inject constructor( private fun observeWatcherEvents() { viewModelScope.launch(bgDispatcher) { trezorRepo.watcherEvents.collect { (watcherId, event) -> - if (watcherId != _uiState.value.activeWatcherId) return@collect + if (watcherId != _uiState.value.watcherId) return@collect when (event) { is WatcherEvent.TransactionsChanged -> _uiState.update { it.copy( @@ -684,7 +691,7 @@ class TrezorViewModel @Inject constructor( } fun startWatcher() { - viewModelScope.launch(bgDispatcher) { + watcherStartScope.launch { val state = _uiState.value val key = state.watcherExtendedKey.trim() if (key.isBlank()) { @@ -702,42 +709,63 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( isStarting = true, - activeWatcherId = watcherId, + activeWatcherId = null, + startingWatcherId = watcherId, connectionStatus = WatcherConnectionStatus.STARTING, events = persistentListOf("Watcher starting: $watcherId"), ) ) } - trezorRepo.startWatcher( + val result = trezorRepo.startWatcher( watcherId = watcherId, extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, ) - .onSuccess { - _uiState.update { - it.copy( - watcher = it.watcher.copy( - isStarting = false, - ) - ) - } - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Watcher started") - } - .onFailure { - _uiState.update { - it.copy( - watcher = it.watcher.copy( - isStarting = false, - activeWatcherId = null, - connectionStatus = WatcherConnectionStatus.IDLE, - events = persistentListOf(), - ) - ) - } - ToastEventBus.send(it) - } + + if (result.isSuccess) { + handleWatcherStartSuccess(watcherId) + } else { + handleWatcherStartFailure(watcherId, result) + } + } + } + + private suspend fun handleWatcherStartSuccess(watcherId: String) { + if (isCleared) { + trezorRepo.stopWatcher(watcherId) + return + } + + _uiState.update { + if (it.watcher.startingWatcherId != watcherId) return@update it + it.copy( + watcher = it.watcher.copy( + isStarting = false, + startingWatcherId = null, + activeWatcherId = watcherId, + ) + ) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Watcher started") + } + + private suspend fun handleWatcherStartFailure(watcherId: String, result: Result) { + if (isCleared) return + + _uiState.update { + if (it.watcher.startingWatcherId != watcherId) return@update it + it.copy( + watcher = it.watcher.copy( + isStarting = false, + startingWatcherId = null, + activeWatcherId = null, + connectionStatus = WatcherConnectionStatus.IDLE, + events = persistentListOf(), + ) + ) + } + result.exceptionOrNull()?.let { ToastEventBus.send(it) } } fun stopWatcher() { @@ -766,6 +794,7 @@ class TrezorViewModel @Inject constructor( } override fun onCleared() { + isCleared = true _uiState.value.activeWatcherId?.let { trezorRepo.stopWatcherOnCleared(it) } super.onCleared() } @@ -929,6 +958,9 @@ data class TrezorUiState( val activeWatcherId: String? get() = watcher.activeWatcherId + val watcherId: String? + get() = watcher.activeWatcherId ?: watcher.startingWatcherId + val watcherConnectionStatus: WatcherConnectionStatus get() = watcher.connectionStatus @@ -1005,6 +1037,7 @@ data class TrezorWatcherState( val extendedKey: String = "", val gapLimit: String = "20", val isStarting: Boolean = false, + val startingWatcherId: String? = null, val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index 8c07046b6d..c31d333b11 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -122,7 +122,7 @@ internal fun WatcherSection( } AnimatedVisibility( - visible = uiState.activeWatcherId != null, + visible = uiState.isStartingWatcher || uiState.activeWatcherId != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { 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 e85006a9c3..3be18050df 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 @@ -372,18 +372,28 @@ class TrezorViewModelTest : BaseUnitTest() { } @Test - fun `startWatcher should keep status starting until watcher event arrives`() = test { + fun `startWatcher should not expose active watcher until start completes`() = test { + val startResult = CompletableDeferred>() whenever(trezorRepo.startWatcher(any(), any(), any(), any())) - .thenReturn(Result.success(Unit)) + .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() advanceUntilIdle() - val state = sut.uiState.value - assertFalse(state.isStartingWatcher) - assertNotNull(state.activeWatcherId) - assertEquals(WatcherConnectionStatus.STARTING, state.watcherConnectionStatus) + val startingState = sut.uiState.value + val watcherId = assertNotNull(startingState.watcherId) + assertTrue(startingState.isStartingWatcher) + assertNull(startingState.activeWatcherId) + assertEquals(WatcherConnectionStatus.STARTING, startingState.watcherConnectionStatus) + + startResult.complete(Result.success(Unit)) + advanceUntilIdle() + + val startedState = sut.uiState.value + assertFalse(startedState.isStartingWatcher) + assertEquals(watcherId, startedState.activeWatcherId) + assertEquals(WatcherConnectionStatus.STARTING, startedState.watcherConnectionStatus) } @Test @@ -424,6 +434,41 @@ class TrezorViewModelTest : BaseUnitTest() { assertEquals(3u, state.watcherTransactionCount) } + @Test + fun `watcher event should be handled while start is in flight`() = test { + val startResult = CompletableDeferred>() + whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + .doSuspendableAnswer { startResult.await() } + sut.setWatcherExtendedKey("xpub6test123") + sut.startWatcher() + advanceUntilIdle() + val watcherId = assertNotNull(sut.uiState.value.watcherId) + + watcherEventsFlow.emit( + watcherId to WatcherEvent.TransactionsChanged( + transactions = TrezorPreviewData.sampleHistoryTransactions, + balance = TrezorPreviewData.sampleWalletBalance, + txCount = 3u, + blockHeight = 850_000u, + accountType = TrezorPreviewData.sampleTransactionHistoryResult.accountType, + ), + ) + advanceUntilIdle() + + val startingState = sut.uiState.value + assertTrue(startingState.isStartingWatcher) + assertNull(startingState.activeWatcherId) + assertEquals(WatcherConnectionStatus.CONNECTED, startingState.watcherConnectionStatus) + + startResult.complete(Result.success(Unit)) + advanceUntilIdle() + + val startedState = sut.uiState.value + assertFalse(startedState.isStartingWatcher) + assertEquals(watcherId, startedState.activeWatcherId) + assertEquals(WatcherConnectionStatus.CONNECTED, startedState.watcherConnectionStatus) + } + @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { whenever(trezorRepo.startWatcher(any(), any(), any(), any())) From 12b210ab994b1ef9f545cc795dedd728a7ee8f7d Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 8 Jun 2026 09:00:48 -0400 Subject: [PATCH 07/10] feat: add account-type override to watcher --- .../java/to/bitkit/repositories/TrezorRepo.kt | 3 +- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 4 ++ .../ui/screens/trezor/TrezorViewModel.kt | 9 ++++ .../ui/screens/trezor/WatcherSection.kt | 46 +++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 61f2208875..5f7b467167 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -577,6 +577,7 @@ class TrezorRepo @Inject constructor( extendedKey: String, network: BitkitCoreNetwork, gapLimit: UInt = 20u, + accountType: AccountType? = null, ): Result = withContext(ioDispatcher) { runCatching { val params = WatcherParams( @@ -584,7 +585,7 @@ class TrezorRepo @Inject constructor( extendedKey = extendedKey, electrumUrl = electrumUrlForNetwork(network), network = network, - accountType = null, + accountType = accountType, gapLimit = gapLimit, ) trezorService.startWatcher(params, eventBridge) 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 7a8728cf40..5b98f8c7ad 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 @@ -44,6 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import kotlinx.collections.immutable.toImmutableList import to.bitkit.R @@ -169,6 +170,7 @@ private fun TrezorScreenContent( onLookupTxHistory = viewModel::lookupTransactionHistory, onWatcherExtendedKeyChange = viewModel::setWatcherExtendedKey, onWatcherGapLimitChange = viewModel::setWatcherGapLimit, + onWatcherAccountTypeChange = viewModel::setWatcherAccountType, onStartWatcher = viewModel::startWatcher, onStopWatcher = viewModel::stopWatcher, onPopulateWatcherFromXpub = viewModel::populateWatcherFromXpub, @@ -214,6 +216,7 @@ private fun Content( onLookupTxHistory: () -> Unit = {}, onWatcherExtendedKeyChange: (String) -> Unit = {}, onWatcherGapLimitChange: (String) -> Unit = {}, + onWatcherAccountTypeChange: (AccountType?) -> Unit = {}, onStartWatcher: () -> Unit = {}, onStopWatcher: () -> Unit = {}, onPopulateWatcherFromXpub: () -> Unit = {}, @@ -444,6 +447,7 @@ private fun Content( trezorState = trezorState, onExtendedKeyChange = onWatcherExtendedKeyChange, onGapLimitChange = onWatcherGapLimitChange, + onAccountTypeChange = onWatcherAccountTypeChange, onStartWatcher = onStartWatcher, onStopWatcher = onStopWatcher, onPopulateFromXpub = onPopulateWatcherFromXpub, 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 2254b3e630..1e0d17e31d 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 @@ -685,6 +685,10 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(watcher = it.watcher.copy(gapLimit = limit)) } } + fun setWatcherAccountType(type: AccountType?) { + _uiState.update { it.copy(watcher = it.watcher.copy(selectedAccountType = type)) } + } + fun populateWatcherFromXpub() { val xpub = trezorRepo.state.value.lastPublicKey?.xpub ?: return _uiState.update { it.copy(watcher = it.watcher.copy(extendedKey = xpub)) } @@ -721,6 +725,7 @@ class TrezorViewModel @Inject constructor( extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, + accountType = state.watcher.selectedAccountType, ) if (result.isSuccess) { @@ -979,6 +984,9 @@ data class TrezorUiState( val watcherAccountType: AccountType? get() = watcher.accountType + val watcherSelectedAccountType: AccountType? + get() = watcher.selectedAccountType + val watcherEvents: ImmutableList get() = watcher.events } @@ -1045,6 +1053,7 @@ data class TrezorWatcherState( val transactionCount: UInt = 0u, val blockHeight: UInt = 0u, val accountType: AccountType? = null, + val selectedAccountType: AccountType? = null, val events: ImmutableList = persistentListOf(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index c31d333b11..8e1aab1b7a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -23,6 +24,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.TxDirection import to.bitkit.models.safe import to.bitkit.repositories.TrezorState @@ -33,6 +35,7 @@ import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -44,6 +47,7 @@ internal fun WatcherSection( trezorState: TrezorState, onExtendedKeyChange: (String) -> Unit, onGapLimitChange: (String) -> Unit, + onAccountTypeChange: (AccountType?) -> Unit, onStartWatcher: () -> Unit, onStopWatcher: () -> Unit, onPopulateFromXpub: () -> Unit, @@ -103,6 +107,13 @@ internal fun WatcherSection( ) } + VerticalSpacer(8.dp) + + AccountTypeSelectorRow( + selectedAccountType = uiState.watcherSelectedAccountType, + onAccountTypeChange = onAccountTypeChange, + ) + VerticalSpacer(16.dp) if (uiState.activeWatcherId != null) { @@ -134,6 +145,39 @@ internal fun WatcherSection( } } +private fun AccountType?.label(): String = when (this) { + null -> "Auto" + AccountType.LEGACY -> "Legacy" + AccountType.WRAPPED_SEGWIT -> "Wrapped" + AccountType.NATIVE_SEGWIT -> "Native" + AccountType.TAPROOT -> "Taproot" +} + +@Composable +private fun AccountTypeSelectorRow( + selectedAccountType: AccountType?, + onAccountTypeChange: (AccountType?) -> Unit, +) { + Column { + Caption("Account type (Auto = detect from key prefix)", color = Colors.White50) + VerticalSpacer(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + val options = listOf(null) + AccountType.entries + options.forEach { type -> + TagButton( + text = type.label(), + onClick = { onAccountTypeChange(type) }, + isSelected = type == selectedAccountType, + ) + } + } + } +} + private fun WatcherConnectionStatus.toColor(): Color = when (this) { WatcherConnectionStatus.IDLE -> Colors.White50 WatcherConnectionStatus.STARTING -> Colors.Yellow @@ -242,6 +286,7 @@ private fun PreviewWatcherEmpty() { trezorState = TrezorState(), onExtendedKeyChange = {}, onGapLimitChange = {}, + onAccountTypeChange = {}, onStartWatcher = {}, onStopWatcher = {}, onPopulateFromXpub = {}, @@ -258,6 +303,7 @@ private fun PreviewWatcherActive() { trezorState = TrezorState(), onExtendedKeyChange = {}, onGapLimitChange = {}, + onAccountTypeChange = {}, onStartWatcher = {}, onStopWatcher = {}, onPopulateFromXpub = {}, From 1488624086055f6fb29a7c9b87d4c327a0aecef3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 8 Jun 2026 10:38:16 -0400 Subject: [PATCH 08/10] fix: extend account-type override to lookups --- .../ui/screens/trezor/BalanceLookupSection.kt | 13 +++++++ .../trezor/TransactionHistorySection.kt | 12 +++++++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 6 ++++ .../ui/screens/trezor/TrezorViewModel.kt | 22 ++++++++++-- .../ui/screens/trezor/WatcherSection.kt | 35 ------------------- 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 46a7f3b73b..ed92795a43 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AccountUtxo import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.SingleAddressInfoResult @@ -40,6 +41,7 @@ internal fun BalanceLookupSection( uiState: TrezorUiState, isDeviceConnected: Boolean, onInputChange: (String) -> Unit, + onAccountTypeChange: (AccountType?) -> Unit, onLookup: () -> Unit, onSendAddressChange: (String) -> Unit, onSendAmountChange: (String) -> Unit, @@ -74,6 +76,13 @@ internal fun BalanceLookupSection( modifier = Modifier.fillMaxWidth(), ) + VerticalSpacer(8.dp) + + AccountTypeSelectorRow( + selectedAccountType = uiState.lookupSelectedAccountType, + onAccountTypeChange = onAccountTypeChange, + ) + VerticalSpacer(16.dp) PrimaryButton( @@ -264,6 +273,7 @@ private fun PreviewBalanceLookupEmpty() { uiState = TrezorUiState(), isDeviceConnected = false, onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, onSendAddressChange = {}, onSendAmountChange = {}, @@ -287,6 +297,7 @@ private fun PreviewBalanceLookupWithAccountInfo() { uiState = TrezorPreviewData.uiStateWithAccountInfo, isDeviceConnected = true, onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, onSendAddressChange = {}, onSendAmountChange = {}, @@ -310,6 +321,7 @@ private fun PreviewBalanceLookupWithAddressInfo() { uiState = TrezorPreviewData.uiStateWithAddressInfo, isDeviceConnected = false, onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, onSendAddressChange = {}, onSendAmountChange = {}, @@ -338,6 +350,7 @@ private fun PreviewBalanceLookupLoading() { ), isDeviceConnected = false, onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, onSendAddressChange = {}, onSendAmountChange = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TransactionHistorySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TransactionHistorySection.kt index 70a8f8dabd..1df7a4b88f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TransactionHistorySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TransactionHistorySection.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TxDirection @@ -36,6 +37,7 @@ import java.util.Locale internal fun TransactionHistorySection( uiState: TrezorUiState, onInputChange: (String) -> Unit, + onAccountTypeChange: (AccountType?) -> Unit, onLookup: () -> Unit, ) { Column { @@ -60,6 +62,13 @@ internal fun TransactionHistorySection( modifier = Modifier.fillMaxWidth(), ) + VerticalSpacer(8.dp) + + AccountTypeSelectorRow( + selectedAccountType = uiState.txHistorySelectedAccountType, + onAccountTypeChange = onAccountTypeChange, + ) + VerticalSpacer(16.dp) PrimaryButton( @@ -165,6 +174,7 @@ private fun PreviewTransactionHistoryEmpty() { TransactionHistorySection( uiState = TrezorUiState(), onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, ) } @@ -182,6 +192,7 @@ private fun PreviewTransactionHistoryLoading() { ), ), onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, ) } @@ -194,6 +205,7 @@ private fun PreviewTransactionHistoryWithResult() { TransactionHistorySection( uiState = TrezorPreviewData.uiStateWithTxHistory, onInputChange = {}, + onAccountTypeChange = {}, onLookup = {}, ) } 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 5b98f8c7ad..9200f329b5 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 @@ -154,6 +154,7 @@ private fun TrezorScreenContent( onMessageChange = viewModel::setMessageToSign, onClearError = viewModel::clearError, onLookupInputChange = viewModel::setLookupInput, + onLookupAccountTypeChange = viewModel::setLookupAccountType, onLookup = viewModel::lookupBalanceInfo, onNetworkChange = viewModel::setSelectedNetwork, onSendAddressChange = viewModel::setSendAddress, @@ -167,6 +168,7 @@ private fun TrezorScreenContent( onBackToForm = viewModel::backToComposeForm, onResetSend = viewModel::resetSendFlow, onTxHistoryInputChange = viewModel::setTxHistoryInput, + onTxHistoryAccountTypeChange = viewModel::setTxHistoryAccountType, onLookupTxHistory = viewModel::lookupTransactionHistory, onWatcherExtendedKeyChange = viewModel::setWatcherExtendedKey, onWatcherGapLimitChange = viewModel::setWatcherGapLimit, @@ -200,6 +202,7 @@ private fun Content( onMessageChange: (String) -> Unit = {}, onClearError: () -> Unit = {}, onLookupInputChange: (String) -> Unit = {}, + onLookupAccountTypeChange: (AccountType?) -> Unit = {}, onLookup: () -> Unit = {}, onNetworkChange: (BitkitCoreNetwork) -> Unit = {}, onSendAddressChange: (String) -> Unit = {}, @@ -213,6 +216,7 @@ private fun Content( onBackToForm: () -> Unit = {}, onResetSend: () -> Unit = {}, onTxHistoryInputChange: (String) -> Unit = {}, + onTxHistoryAccountTypeChange: (AccountType?) -> Unit = {}, onLookupTxHistory: () -> Unit = {}, onWatcherExtendedKeyChange: (String) -> Unit = {}, onWatcherGapLimitChange: (String) -> Unit = {}, @@ -419,6 +423,7 @@ private fun Content( uiState = uiState, isDeviceConnected = trezorState.connectedDevice != null, onInputChange = onLookupInputChange, + onAccountTypeChange = onLookupAccountTypeChange, onLookup = onLookup, onSendAddressChange = onSendAddressChange, onSendAmountChange = onSendAmountChange, @@ -437,6 +442,7 @@ private fun Content( TransactionHistorySection( uiState = uiState, onInputChange = onTxHistoryInputChange, + onAccountTypeChange = onTxHistoryAccountTypeChange, onLookup = onLookupTxHistory, ) 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 1e0d17e31d..bb2831a614 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 @@ -303,6 +303,10 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(lookup = it.lookup.copy(input = input)) } } + fun setLookupAccountType(type: AccountType?) { + _uiState.update { it.copy(lookup = it.lookup.copy(selectedAccountType = type)) } + } + fun lookupBalanceInfo() { viewModelScope.launch(bgDispatcher) { val input = _uiState.value.lookupInput.trim() @@ -323,7 +327,8 @@ class TrezorViewModel @Inject constructor( val network = _uiState.value.selectedNetwork if (isExtendedKey(input)) { - trezorRepo.getAccountInfo(extendedKey = input, network = network) + val scriptType = _uiState.value.lookup.selectedAccountType + trezorRepo.getAccountInfo(extendedKey = input, network = network, scriptType = scriptType) .onSuccess { result -> _uiState.update { it.copy( @@ -648,6 +653,10 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(txHistory = it.txHistory.copy(input = input)) } } + fun setTxHistoryAccountType(type: AccountType?) { + _uiState.update { it.copy(txHistory = it.txHistory.copy(selectedAccountType = type)) } + } + fun lookupTransactionHistory() { viewModelScope.launch(bgDispatcher) { val input = _uiState.value.txHistoryInput.trim() @@ -660,7 +669,8 @@ class TrezorViewModel @Inject constructor( } val network = _uiState.value.selectedNetwork - trezorRepo.getTransactionHistory(extendedKey = input, network = network) + val scriptType = _uiState.value.txHistory.selectedAccountType + trezorRepo.getTransactionHistory(extendedKey = input, network = network, scriptType = scriptType) .onSuccess { result -> _uiState.update { it.copy(txHistory = it.txHistory.copy(isLoading = false, result = result)) @@ -906,6 +916,9 @@ data class TrezorUiState( val addressInfoResult: SingleAddressInfoResult? get() = lookup.addressInfoResult + val lookupSelectedAccountType: AccountType? + get() = lookup.selectedAccountType + val sendAddress: String get() = send.address @@ -951,6 +964,9 @@ data class TrezorUiState( val txHistoryResult: TransactionHistoryResult? get() = txHistory.result + val txHistorySelectedAccountType: AccountType? + get() = txHistory.selectedAccountType + val watcherExtendedKey: String get() = watcher.extendedKey @@ -1018,6 +1034,7 @@ data class TrezorLookupState( val isLookingUp: Boolean = false, val accountInfoResult: AccountInfoResult? = null, val addressInfoResult: SingleAddressInfoResult? = null, + val selectedAccountType: AccountType? = null, ) @Stable @@ -1038,6 +1055,7 @@ data class TrezorTxHistoryState( val input: String = "", val isLoading: Boolean = false, val result: TransactionHistoryResult? = null, + val selectedAccountType: AccountType? = null, ) @Stable diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index 8e1aab1b7a..aeaabafc89 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -35,7 +34,6 @@ import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -145,39 +143,6 @@ internal fun WatcherSection( } } -private fun AccountType?.label(): String = when (this) { - null -> "Auto" - AccountType.LEGACY -> "Legacy" - AccountType.WRAPPED_SEGWIT -> "Wrapped" - AccountType.NATIVE_SEGWIT -> "Native" - AccountType.TAPROOT -> "Taproot" -} - -@Composable -private fun AccountTypeSelectorRow( - selectedAccountType: AccountType?, - onAccountTypeChange: (AccountType?) -> Unit, -) { - Column { - Caption("Account type (Auto = detect from key prefix)", color = Colors.White50) - VerticalSpacer(8.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - val options = listOf(null) + AccountType.entries - options.forEach { type -> - TagButton( - text = type.label(), - onClick = { onAccountTypeChange(type) }, - isSelected = type == selectedAccountType, - ) - } - } - } -} - private fun WatcherConnectionStatus.toColor(): Color = when (this) { WatcherConnectionStatus.IDLE -> Colors.White50 WatcherConnectionStatus.STARTING -> Colors.Yellow From 65031b979acddaeb3cb9292b61b293360ee57428 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 8 Jun 2026 10:38:44 -0400 Subject: [PATCH 09/10] fix: extend account-type override to lookups --- .../ui/screens/trezor/AccountTypeSelector.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/AccountTypeSelector.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AccountTypeSelector.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AccountTypeSelector.kt new file mode 100644 index 0000000000..f2ea4d9290 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AccountTypeSelector.kt @@ -0,0 +1,47 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.AccountType +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.Colors + +internal fun AccountType?.accountTypeLabel(): String = when (this) { + null -> "Auto" + AccountType.LEGACY -> "Legacy" + AccountType.WRAPPED_SEGWIT -> "Wrapped" + AccountType.NATIVE_SEGWIT -> "Native" + AccountType.TAPROOT -> "Taproot" +} + +@Composable +internal fun AccountTypeSelectorRow( + selectedAccountType: AccountType?, + onAccountTypeChange: (AccountType?) -> Unit, +) { + Column { + Caption("Account type (Auto = detect from key prefix)", color = Colors.White50) + VerticalSpacer(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + val options = listOf(null) + AccountType.entries + options.forEach { type -> + TagButton( + text = type.accountTypeLabel(), + onClick = { onAccountTypeChange(type) }, + isSelected = type == selectedAccountType, + ) + } + } + } +} From 53cda0f5a900670522fa80672381eaf34093de16 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 8 Jun 2026 11:40:45 -0400 Subject: [PATCH 10/10] test: add account-type matcher to startWatcher stubs --- .../to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 3be18050df..b9002733a7 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 @@ -374,7 +374,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `startWatcher should not expose active watcher until start completes`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") @@ -404,13 +404,13 @@ class TrezorViewModelTest : BaseUnitTest() { sut.startWatcher() advanceUntilIdle() - verify(trezorRepo, never()).startWatcher(any(), any(), any(), any()) + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull()) assertNull(sut.uiState.value.activeWatcherId) } @Test fun `watcher transaction event should mark watcher connected`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -437,7 +437,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `watcher event should be handled while start is in flight`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -471,7 +471,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull())) .thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123")