diff --git a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt index c52d531..5faf8cf 100644 --- a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt +++ b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt @@ -104,18 +104,9 @@ private constructor( return sharedFlow } - /** Starts two coroutines that collect [infoLog] and [warningLog] into Android Logcat. */ - fun logToLogcat() { - scope.launch { - infoLog().collect { - Log.i(TAG, it.toString()) - } - } - scope.launch { - warningLog().collect { - Log.i(TAG, it.toString()) - } - } + /** Addresses of the peers the node is currently connected to. Throws [CbfException] once the node has stopped. */ + suspend fun peerInfo(): List { + return kyotoClient.peerInfo() } /** Resolves a hostname to IPv4 addresses via the Kyoto client. */ diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt index f74b43a..284f822 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt @@ -61,9 +61,9 @@ import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.recoveryScanCh /** * Settings screen for managing the Kyoto Compact Block Filters (CBF) node. * - * Shows node status, latest known block height, a configurable peer list, and Start/Stop controls that dispatch to - * [WalletViewModel]. A wallet that has never scanned the chain gets a [ScanTypeDialog] asking where to start; once it - * has scanned once, starting the node just resumes from the wallet's own checkpoint. + * Shows node status, the chain tip, the number of connected peers, a configurable peer list, and Start/Stop controls + * that dispatch to [WalletViewModel]. A wallet that has never scanned the chain gets a [ScanTypeDialog] asking where to + * start; once it has scanned once, starting the node just resumes from the wallet's own checkpoint. */ @Composable internal fun CbfNodeScreen( @@ -73,6 +73,12 @@ internal fun CbfNodeScreen( ) { val colorScheme = MaterialTheme.colorScheme val isRunning = state.kyotoNodeStatus == CbfNodeStatus.Running + val nodeStatus = + when { + !isRunning -> NodeStatusDisplay("Offline", Color(0xFFE76F51)) + state.connectedPeerCount == 0 -> NodeStatusDisplay("Connecting", Color(0xFFE9C46A)) + else -> NodeStatusDisplay("Online", Color(0xFF8FD998)) + } var showScanTypeDialog by rememberSaveable { mutableStateOf(false) } if (showScanTypeDialog) { @@ -117,8 +123,8 @@ internal fun CbfNodeScreen( textAlign = TextAlign.Start, ) Text( - text = if (isRunning) "Online" else "Offline", - color = if (isRunning) Color(0xFF8FD998) else Color(0xFFE76F51), + text = nodeStatus.label, + color = nodeStatus.color, fontSize = 14.sp, fontWeight = FontWeight.Bold, fontFamily = inter, @@ -126,26 +132,9 @@ internal fun CbfNodeScreen( ) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - ) { - Text( - text = "Latest known block:", - color = colorScheme.onSurface, - fontSize = 14.sp, - fontFamily = inter, - textAlign = TextAlign.Start, - ) - Text( - text = "${state.bestBlockHeight}", - color = colorScheme.onSurface, - fontSize = 14.sp, - fontFamily = inter, - textAlign = TextAlign.Start, - ) - } + NodeInfoRow(label = "Chain tip:", value = "${state.bestBlockHeight}") + + NodeInfoRow(label = "Connected peers:", value = "${state.connectedPeerCount}") Spacer(modifier = Modifier.padding(8.dp)) HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.30f)) @@ -203,6 +192,35 @@ internal fun CbfNodeScreen( } } +/** Label and color used to render the node's high-level status. */ +private data class NodeStatusDisplay(val label: String, val color: Color) + +/** A label/value line in the node info section, matching the layout used across [CbfNodeScreen]. */ +@Composable +private fun NodeInfoRow(label: String, value: String) { + val colorScheme = MaterialTheme.colorScheme + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + Text( + text = label, + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + Text( + text = value, + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.End, + ) + } +} + /** * Dialog shown the first time the user starts the node on a wallet that has never scanned the chain, asking whether * these keys are new. diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index 2d64671..62699f2 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.bitcoindevkit.CbfException +import org.bitcoindevkit.Info import org.bitcoindevkit.Network import org.bitcoindevkit.RecoveryPoint import org.bitcoindevkit.ScanType @@ -128,9 +130,10 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { val peers = state.value.customPeers.ifEmpty { listOfNotNull(defaultPeer) } val dataDir = wallet.internalAppFilesPath - this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers, scanChoice.toScanType(wallet.network)) - val updatesFlow = kyoto!!.start() - state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Running) } + val kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers, scanChoice.toScanType(wallet.network)) + this.kyoto = kyoto + val updatesFlow = kyoto.start() + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Running, connectedPeerCount = 0) } kyotoCoroutineScope.launch { var previousHeight: UInt = wallet.bestBlock() @@ -153,15 +156,48 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { // The updates flow ends when the node stops, whether requested or on its own Log.i(TAG, "Kyoto updates flow ended, node is no longer running") - state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped) } + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped, connectedPeerCount = 0) } } - kyoto!!.logToLogcat() + + // Single consumer of the node's info log: mirrors it to Logcat and refreshes the peer count. The flow + // completes on its own once the node stops. The peer set is only re-read when a connection is made or + // handshaked, since those are the events that change it. + kyotoCoroutineScope.launch { + kyoto.infoLog().collect { info -> + Log.i(TAG, info.toString()) + when (info) { + Info.ConnectionsMet, + Info.SuccessfulHandshake -> refreshConnectedPeerCount(kyoto) + else -> {} + } + } + } + + // Warnings such as dropped or failed connections can shrink the peer set, so re-read it on each one. + kyotoCoroutineScope.launch { + kyoto.warningLog().collect { warning -> + Log.i(TAG, warning.toString()) + refreshConnectedPeerCount(kyoto) + } + } + } + + /** Reads the node's current peer count into [state], ignoring the error thrown once the node has stopped. */ + private suspend fun refreshConnectedPeerCount(kyoto: Kyoto) { + val count = + try { + kyoto.peerInfo().size + } catch (e: CbfException) { + Log.i(TAG, "Skipping peer refresh, node is no longer running: ${e.message}") + return + } + state.update { it.copy(connectedPeerCount = count) } } /** Requests a graceful shutdown of the Kyoto node. */ private fun stopKyotoNode() { kyoto!!.shutdown() - state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped) } + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped, connectedPeerCount = 0) } } /** Reads the wallet's latest checkpoint height into [state]. */ diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt index da07d59..b5d39e9 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt @@ -17,6 +17,7 @@ import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit * @property network The active Bitcoin network. * @property bestBlockHeight Chain tip known to the wallet. * @property kyotoNodeStatus Whether the Kyoto CBF node is running or stopped. + * @property connectedPeerCount Number of peers the running node is currently connected to. * @property defaultPeer Hard-coded default peer for the current network, if any. * @property customPeers User-added peers for the Kyoto node. * @property initialRecoveryDone Whether this wallet has ever completed a scan of the chain. @@ -27,6 +28,7 @@ data class WalletScreenState( val network: Network = Network.SIGNET, val bestBlockHeight: UInt = 0u, val kyotoNodeStatus: CbfNodeStatus = CbfNodeStatus.Stopped, + val connectedPeerCount: Int = 0, val defaultPeer: NodePeer? = null, val customPeers: List = emptyList(), val initialRecoveryDone: Boolean = false,