diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index f12afe229..18adba263 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 ee68e241e..ca7dac691 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 e78ad9942..2842e4200 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 5249442fb..7a8728cf4 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 f6021b2f8..c1ecab7fd 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 000000000..275e269a1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -0,0 +1,264 @@ +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, + enabled = !uiState.isStartingWatcher, + 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 b151e6f18..8ebea41c7 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 18c654b70..9ca7f233a 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.64" } +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" }