From 2321e33d5d3b577c30acb887612f7943158a1b0e Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Thu, 16 Jul 2026 09:19:16 -0400 Subject: [PATCH 1/5] feat: allow setting custom peers when initializing the node --- .../bitcoindevkit/devkitwallet/data/Kyoto.kt | 65 ++++--- .../settings/BlockchainClientScreen.kt | 168 +++++++++++++++++- .../viewmodels/WalletViewModel.kt | 25 ++- .../viewmodels/mvi/MviWalletScreen.kt | 7 + 4 files changed, 241 insertions(+), 24 deletions(-) diff --git a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt index d4db8da..999264c 100644 --- a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt +++ b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt @@ -6,7 +6,6 @@ package org.bitcoindevkit.devkitwallet.data import android.util.Log -import kotlin.collections.listOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -106,30 +105,22 @@ private constructor( } companion object { + private const val TAG = "KyotoClient" + private var instance: Kyoto? = null fun getInstance(): Kyoto = instance ?: throw KyotoNotInitialized() - fun create(wallet: Wallet, dataDir: String, network: Network): Kyoto { - Log.i(TAG, "Starting Kyoto node") - val peers: List = - when (network) { - Network.REGTEST -> { - val ip: IpAddress = IpAddress.fromIpv4(10u, 0u, 2u, 2u) - val peer1: Peer = Peer(ip, 18444u, false) - listOf(peer1) - } - - Network.SIGNET -> { - val ip: IpAddress = IpAddress.fromIpv4(68u, 47u, 229u, 218u) - val peer1: Peer = Peer(ip, null, false) - listOf(peer1) - } - - else -> { - listOf() - } - } + fun defaultPeer(network: Network): NodePeer? = + when (network) { + // Default connection point from the emulator with a local regtest setup + Network.REGTEST -> NodePeer(ip = "10.0.2.2", port = 18444u) + else -> null + } + + fun create(wallet: Wallet, dataDir: String, network: Network, nodePeers: List): Kyoto { + Log.i(TAG, "Starting Kyoto node with peers: $nodePeers") + val peers: List = nodePeers.map { it.toPeer() } val (client, node) = CbfBuilder().dataDir(dataDir).peers(peers).connections(1u).scanType(ScanType.Sync).build(wallet) @@ -140,3 +131,35 @@ private constructor( } class KyotoNotInitialized : Exception() + +/** + * A peer the Kyoto node can connect to, kept as simple displayable values. A null port means the default port for the + * network is used. + */ +data class NodePeer(val ip: String, val port: UShort?) { + fun toPeer(): Peer { + val octets = ip.split(".").map { it.toUByte() } + val ipAddress: IpAddress = IpAddress.fromIpv4(octets[0], octets[1], octets[2], octets[3]) + return Peer(ipAddress, port, false) + } + + override fun toString(): String = if (port != null) "$ip:$port" else ip + + companion object { + /** Parses user input into a [NodePeer], returning null if the IP address or port is invalid. */ + fun fromInput(ip: String, port: String): NodePeer? { + val octets = ip.trim().split(".") + if (octets.size != 4 || octets.any { it.toUByteOrNull() == null }) return null + + val trimmedPort = port.trim() + val parsedPort: UShort? = + if (trimmedPort.isEmpty()) { + null + } else { + trimmedPort.toUShortOrNull() ?: return null + } + + return NodePeer(ip.trim(), parsedPort) + } + } +} diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt index 5ad4cd4..da2d088 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt @@ -15,11 +15,24 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,6 +41,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.data.NodePeer import org.bitcoindevkit.devkitwallet.presentation.theme.inter import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar @@ -55,7 +69,11 @@ internal fun BlockchainClientScreen( Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize().padding(paddingValues).padding(vertical = 32.dp, horizontal = 16.dp), + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding(vertical = 32.dp, horizontal = 16.dp) + .verticalScroll(rememberScrollState()), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -106,6 +124,12 @@ internal fun BlockchainClientScreen( ) } + Spacer(modifier = Modifier.padding(8.dp)) + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.30f)) + Spacer(modifier = Modifier.padding(8.dp)) + + PeersSection(state = state, onAction = onAction) + Spacer(modifier = Modifier.padding(16.dp)) NeutralButton( @@ -121,3 +145,145 @@ internal fun BlockchainClientScreen( } } } + +@Composable +private fun PeersSection(state: WalletScreenState, onAction: (WalletScreenAction) -> Unit) { + val colorScheme = MaterialTheme.colorScheme + var ipInput by rememberSaveable { mutableStateOf("") } + var portInput by rememberSaveable { mutableStateOf("") } + var showInvalidPeerError by rememberSaveable { mutableStateOf(false) } + + val defaultPeerText = + when { + state.customPeers.isNotEmpty() -> "Default peer (unused while custom peers are set):" + state.defaultPeer != null -> "Default peer:" + else -> "No default peer for this network. Peers will be discovered automatically." + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = defaultPeerText, + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + state.defaultPeer?.let { peer -> + Text( + text = peer.toString(), + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.End, + ) + } + } + + state.customPeers.forEach { peer -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text( + text = "Custom peer: $peer", + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + IconButton(onClick = { onAction(WalletScreenAction.RemoveCustomPeer(peer)) }) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Remove peer $peer", + tint = colorScheme.onSurface, + ) + } + } + } + + val textFieldColors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = colorScheme.primary, + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.30f), + cursorColor = colorScheme.primary, + focusedLabelColor = colorScheme.primary, + unfocusedLabelColor = colorScheme.onSurface.copy(alpha = 0.5f), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + OutlinedTextField( + modifier = Modifier.weight(2f), + value = ipInput, + onValueChange = { + ipInput = it + showInvalidPeerError = false + }, + label = { Text(text = "Peer IP address", fontFamily = inter) }, + placeholder = { + Text( + text = "192.168.0.1", + color = colorScheme.onSurface.copy(alpha = 0.3f), + fontFamily = inter, + ) + }, + singleLine = true, + isError = showInvalidPeerError, + colors = textFieldColors, + ) + OutlinedTextField( + modifier = Modifier.weight(1f), + value = portInput, + onValueChange = { + portInput = it + showInvalidPeerError = false + }, + label = { Text(text = "Port", fontFamily = inter) }, + placeholder = { + Text( + text = "Default", + color = colorScheme.onSurface.copy(alpha = 0.3f), + fontFamily = inter, + ) + }, + singleLine = true, + isError = showInvalidPeerError, + colors = textFieldColors, + ) + } + + // A small text field below the input field if the user attempts to add an invalid peer + if (showInvalidPeerError) { + Text( + text = "Invalid IP address or port", + color = colorScheme.error, + fontSize = 12.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.padding(8.dp)) + + NeutralButton( + text = "Add Peer", + enabled = ipInput.isNotBlank(), + onClick = { + if (NodePeer.fromInput(ipInput, portInput) != null) { + onAction(WalletScreenAction.AddCustomPeer(ipInput, portInput)) + ipInput = "" + portInput = "" + } else { + showInvalidPeerError = true + } + }, + ) +} diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index 9707299..602c0fb 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.bitcoindevkit.devkitwallet.data.Kyoto +import org.bitcoindevkit.devkitwallet.data.NodePeer import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit import org.bitcoindevkit.devkitwallet.domain.DwLogger import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO @@ -29,7 +30,8 @@ import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenSt private const val TAG = "WalletViewModel" internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { - var state: WalletScreenState by mutableStateOf(WalletScreenState(network = wallet.network)) + var state: WalletScreenState by + mutableStateOf(WalletScreenState(network = wallet.network, defaultPeer = Kyoto.defaultPeer(wallet.network))) private set private val kyotoCoroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO) @@ -44,9 +46,22 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { WalletScreenAction.UpdateBalance -> updateBalance() WalletScreenAction.ActivateCbfNode -> activateKyoto() WalletScreenAction.StopKyotoNode -> stopKyotoNode() + is WalletScreenAction.AddCustomPeer -> addCustomPeer(action.ip, action.port) + is WalletScreenAction.RemoveCustomPeer -> removeCustomPeer(action.peer) } } + private fun addCustomPeer(ip: String, port: String) { + val peer = NodePeer.fromInput(ip, port) ?: return + if (peer !in state.customPeers) { + state = state.copy(customPeers = state.customPeers + peer) + } + } + + private fun removeCustomPeer(peer: NodePeer) { + state = state.copy(customPeers = state.customPeers - peer) + } + private fun switchUnit() { state = when (state.unit) { @@ -72,8 +87,14 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { } private fun activateKyoto() { + val peers = state.customPeers.ifEmpty { listOfNotNull(state.defaultPeer) } + if (peers.isEmpty()) { + Log.w(TAG, "No peers available for network ${wallet.network}: add a custom peer first") + return + } + val dataDir = wallet.internalAppFilesPath - this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network) + this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers) val updatesFlow = kyoto!!.start() kyotoCoroutineScope.launch { var previousHeight: UInt = wallet.bestBlock() diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt index 9787b77..8479929 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt @@ -6,6 +6,7 @@ package org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.data.NodePeer import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit data class WalletScreenState( @@ -14,6 +15,8 @@ data class WalletScreenState( val network: Network = Network.SIGNET, val bestBlockHeight: UInt = 0u, val kyotoNodeStatus: CbfNodeStatus = CbfNodeStatus.Stopped, + val defaultPeer: NodePeer? = null, + val customPeers: List = emptyList(), ) sealed interface WalletScreenAction { @@ -24,6 +27,10 @@ sealed interface WalletScreenAction { data object ActivateCbfNode : WalletScreenAction data object StopKyotoNode : WalletScreenAction + + data class AddCustomPeer(val ip: String, val port: String) : WalletScreenAction + + data class RemoveCustomPeer(val peer: NodePeer) : WalletScreenAction } enum class CbfNodeStatus { From 475099ec9a2d9e6edfb833874652d89c50cbd6d0 Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Thu, 16 Jul 2026 11:20:59 -0400 Subject: [PATCH 2/5] refactor: use StateFlow pattern in viewmodels --- module.yaml | 2 + .../presentation/navigation/AppNavigation.kt | 11 +++-- .../viewmodels/AddressViewModel.kt | 13 ++--- .../viewmodels/WalletViewModel.kt | 47 +++++++++---------- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/module.yaml b/module.yaml index 728ddb9..a099307 100644 --- a/module.yaml +++ b/module.yaml @@ -8,6 +8,7 @@ settings: compose: enabled kotlin: serialization: json + freeCompilerArgs: [ -Xexplicit-backing-fields ] junit: junit-4 dependencies: @@ -23,6 +24,7 @@ dependencies: - androidx.compose.ui:ui - androidx.compose.material3:material3 - androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0 + - androidx.lifecycle:lifecycle-runtime-compose:2.10.0 - androidx.navigation:navigation-compose:2.9.7 # Icons diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt b/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt index 827877f..36948cc 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt @@ -15,7 +15,9 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -135,8 +137,9 @@ fun AppNavigation( // Wallet screens composable { + val state by walletViewModel!!.state.collectAsStateWithLifecycle() WalletHomeScreen( - state = walletViewModel!!.state, + state = state, onAction = walletViewModel::onAction, snackbarMessages = walletViewModel.snackbarMessages, navController = navController, @@ -144,8 +147,9 @@ fun AppNavigation( } composable { + val state by addressViewModel!!.state.collectAsStateWithLifecycle() ReceiveScreen( - state = addressViewModel!!.state, + state = state, onAction = addressViewModel::onAction, navController = navController, ) @@ -175,8 +179,9 @@ fun AppNavigation( } composable { + val state by walletViewModel!!.state.collectAsStateWithLifecycle() BlockchainClientScreen( - state = walletViewModel!!.state, + state = state, onAction = walletViewModel::onAction, navController = navController, ) diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt index e9e4a83..8f8c0f9 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt @@ -5,10 +5,10 @@ package org.bitcoindevkit.devkitwallet.presentation.viewmodels -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.bitcoindevkit.AddressInfo import org.bitcoindevkit.devkitwallet.domain.DwLogger import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO @@ -17,8 +17,8 @@ import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenA import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenState internal class AddressViewModel(private val wallet: Wallet) : ViewModel() { - var state: ReceiveScreenState by mutableStateOf(ReceiveScreenState()) - private set + val state: StateFlow + field = MutableStateFlow(ReceiveScreenState()) fun onAction(action: ReceiveScreenAction) { when (action) { @@ -30,10 +30,11 @@ internal class AddressViewModel(private val wallet: Wallet) : ViewModel() { val newAddress: AddressInfo = wallet.getNewAddress() DwLogger.log(INFO, "Revealing new address at index ${newAddress.index}") - state = + state.update { ReceiveScreenState( address = newAddress.address.toString(), addressIndex = newAddress.index, ) + } } } diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index 602c0fb..4e2c8ee 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -6,9 +6,6 @@ package org.bitcoindevkit.devkitwallet.presentation.viewmodels import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineScope @@ -16,7 +13,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.bitcoindevkit.devkitwallet.data.Kyoto import org.bitcoindevkit.devkitwallet.data.NodePeer @@ -30,9 +30,10 @@ import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenSt private const val TAG = "WalletViewModel" internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { - var state: WalletScreenState by - mutableStateOf(WalletScreenState(network = wallet.network, defaultPeer = Kyoto.defaultPeer(wallet.network))) - private set + val defaultPeer: NodePeer? = Kyoto.defaultPeer(wallet.network) + + val state: StateFlow + field = MutableStateFlow(WalletScreenState(network = wallet.network, defaultPeer = defaultPeer)) private val kyotoCoroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO) private var kyoto: Kyoto? = null @@ -53,25 +54,26 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { private fun addCustomPeer(ip: String, port: String) { val peer = NodePeer.fromInput(ip, port) ?: return - if (peer !in state.customPeers) { - state = state.copy(customPeers = state.customPeers + peer) + state.update { + if (peer in it.customPeers) it else it.copy(customPeers = it.customPeers + peer) } } private fun removeCustomPeer(peer: NodePeer) { - state = state.copy(customPeers = state.customPeers - peer) + state.update { it.copy(customPeers = it.customPeers - peer) } } private fun switchUnit() { - state = - when (state.unit) { - CurrencyUnit.Bitcoin -> state.copy(unit = CurrencyUnit.Satoshi) - CurrencyUnit.Satoshi -> state.copy(unit = CurrencyUnit.Bitcoin) + state.update { + when (it.unit) { + CurrencyUnit.Bitcoin -> it.copy(unit = CurrencyUnit.Satoshi) + CurrencyUnit.Satoshi -> it.copy(unit = CurrencyUnit.Bitcoin) } + } } private fun updateLatestBlock(blockHeight: UInt) { - state = state.copy(bestBlockHeight = blockHeight) + state.update { it.copy(bestBlockHeight = blockHeight) } } private fun updateBalance() { @@ -80,18 +82,15 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { Log.i("Kyoto", "New balance: $newBalance") DwLogger.log(INFO, "New balance: $newBalance") - state = state.copy(balance = newBalance) - Log.i("Kyoto", "New state object: $state") - DwLogger.log(INFO, "New state object: $state") + state.update { it.copy(balance = newBalance) } + Log.i("Kyoto", "New state object: ${state.value}") + DwLogger.log(INFO, "New state object: ${state.value}") } } private fun activateKyoto() { - val peers = state.customPeers.ifEmpty { listOfNotNull(state.defaultPeer) } - if (peers.isEmpty()) { - Log.w(TAG, "No peers available for network ${wallet.network}: add a custom peer first") - return - } + // An empty list is fine: Kyoto discovers peers on its own if none are provided + val peers = state.value.customPeers.ifEmpty { listOfNotNull(defaultPeer) } val dataDir = wallet.internalAppFilesPath this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers) @@ -105,7 +104,7 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { updateBalance() updateBestBlock() - val newHeight = state.bestBlockHeight + val newHeight = state.value.bestBlockHeight if (newHeight > previousHeight) { snackbarChannel.send("New block: $newHeight") } @@ -121,6 +120,6 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { private fun updateBestBlock() { val bestBlockHeight = wallet.bestBlock() - state = state.copy(bestBlockHeight = bestBlockHeight) + state.update { it.copy(bestBlockHeight = bestBlockHeight) } } } From d720014bf3dee5c6a80ce9ed3a9b4f1cd60d101b Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Thu, 16 Jul 2026 11:47:44 -0400 Subject: [PATCH 3/5] ui: clean up CbfNodeScreen --- ...kchainClientScreen.kt => CbfNodeScreen.kt} | 108 ++++++++++++------ 1 file changed, 73 insertions(+), 35 deletions(-) rename src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/{BlockchainClientScreen.kt => CbfNodeScreen.kt} (76%) diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt similarity index 76% rename from src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt rename to src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt index da2d088..2fd62e6 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt @@ -5,25 +5,27 @@ package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold @@ -35,27 +37,27 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import org.bitcoindevkit.devkitwallet.data.NodePeer import org.bitcoindevkit.devkitwallet.presentation.theme.inter -import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.CbfNodeStatus import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState @Composable -internal fun BlockchainClientScreen( +internal fun CbfNodeScreen( state: WalletScreenState, onAction: (WalletScreenAction) -> Unit, navController: NavController, ) { val colorScheme = MaterialTheme.colorScheme + val isRunning = state.kyotoNodeStatus == CbfNodeStatus.Running Scaffold( topBar = { @@ -80,26 +82,20 @@ internal fun BlockchainClientScreen( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { - val status = if (state.kyotoNodeStatus == CbfNodeStatus.Running) "Online" else "Offline" Text( - text = "CBF Node Status: $status", + text = "CBF Node Status:", color = colorScheme.onSurface, fontSize = 14.sp, fontFamily = inter, textAlign = TextAlign.Start, ) - Box( - modifier = - Modifier.padding(horizontal = 8.dp) - .size(size = 21.dp) - .clip(shape = CircleShape) - .background( - if (state.kyotoNodeStatus == CbfNodeStatus.Running) { - Color(0xFF8FD998) - } else { - Color(0xFFE76F51) - } - ) + Text( + text = if (isRunning) "Online" else "Offline", + color = if (isRunning) Color(0xFF8FD998) else Color(0xFFE76F51), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = inter, + textAlign = TextAlign.End, ) } @@ -130,18 +126,46 @@ internal fun BlockchainClientScreen( PeersSection(state = state, onAction = onAction) - Spacer(modifier = Modifier.padding(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) - NeutralButton( - text = "Start Node", - enabled = state.kyotoNodeStatus == CbfNodeStatus.Stopped, + Button( onClick = { onAction(WalletScreenAction.ActivateCbfNode) }, - ) - NeutralButton( - text = "Stop Node", - enabled = state.kyotoNodeStatus == CbfNodeStatus.Running, + enabled = !isRunning, + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary, + disabledContainerColor = colorScheme.secondary.copy(alpha = 0.4f), + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { + Text( + text = "Start Node", + fontFamily = inter, + fontSize = 15.sp, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( onClick = { onAction(WalletScreenAction.StopKyotoNode) }, - ) + enabled = isRunning, + shape = RoundedCornerShape(16.dp), + border = + BorderStroke( + 1.5.dp, + if (isRunning) colorScheme.primary else colorScheme.primary.copy(alpha = 0.4f), + ), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { + Text( + text = "Stop Node", + fontFamily = inter, + fontSize = 15.sp, + color = if (isRunning) colorScheme.primary else colorScheme.primary.copy(alpha = 0.4f), + ) + } } } } @@ -271,11 +295,10 @@ private fun PeersSection(state: WalletScreenState, onAction: (WalletScreenAction ) } - Spacer(modifier = Modifier.padding(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - NeutralButton( - text = "Add Peer", - enabled = ipInput.isNotBlank(), + val addPeerEnabled = ipInput.isNotBlank() + OutlinedButton( onClick = { if (NodePeer.fromInput(ipInput, portInput) != null) { onAction(WalletScreenAction.AddCustomPeer(ipInput, portInput)) @@ -285,5 +308,20 @@ private fun PeersSection(state: WalletScreenState, onAction: (WalletScreenAction showInvalidPeerError = true } }, - ) + enabled = addPeerEnabled, + shape = RoundedCornerShape(16.dp), + border = + BorderStroke( + 1.5.dp, + if (addPeerEnabled) colorScheme.primary else colorScheme.primary.copy(alpha = 0.4f), + ), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { + Text( + text = "Add Peer", + fontFamily = inter, + fontSize = 15.sp, + color = if (addPeerEnabled) colorScheme.primary else colorScheme.primary.copy(alpha = 0.4f), + ) + } } From 698f8d5689a6b7ad9e4b477b9e6bfa95e61c9fd1 Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Thu, 16 Jul 2026 12:06:51 -0400 Subject: [PATCH 4/5] fix: correctly identify the node as Online/Offline in UI --- justfile | 9 ++++++--- .../presentation/navigation/AppNavigation.kt | 4 ++-- .../presentation/viewmodels/WalletViewModel.kt | 3 +++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/justfile b/justfile index da87ac9..d4c590d 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,12 @@ default: just --list --unsorted -# Run the app in an emulator -run: - ./kotlin run +@devices: + adb devices + +# Run the app in an emulator or on a device (list your connected devices using `just devices`) +run DEVICE="": + ./kotlin run {{ if DEVICE == "" { "" } else { "--device-id=" + DEVICE } }} # Build the docs website docs-build: diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt b/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt index 36948cc..a49ead0 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt @@ -31,7 +31,7 @@ import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.CreateNewWal import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.RecoverWalletScreen import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.WalletChoiceScreen import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.AboutScreen -import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.BlockchainClientScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.CbfNodeScreen import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.LogsScreen import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.RecoveryDataScreen import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.SettingsScreen @@ -180,7 +180,7 @@ fun AppNavigation( composable { val state by walletViewModel!!.state.collectAsStateWithLifecycle() - BlockchainClientScreen( + CbfNodeScreen( state = state, onAction = walletViewModel::onAction, navController = navController, diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index 4e2c8ee..f730aec 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -24,6 +24,7 @@ import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit import org.bitcoindevkit.devkitwallet.domain.DwLogger import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.CbfNodeStatus import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState @@ -95,6 +96,7 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { val dataDir = wallet.internalAppFilesPath this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers) val updatesFlow = kyoto!!.start() + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Running) } kyotoCoroutineScope.launch { var previousHeight: UInt = wallet.bestBlock() @@ -116,6 +118,7 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { private fun stopKyotoNode() { kyoto!!.shutdown() + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped) } } private fun updateBestBlock() { From b893e1bb755de1650ca1f0c63972e850331abef7 Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Thu, 16 Jul 2026 13:22:40 -0400 Subject: [PATCH 5/5] fix: don't crash when the node stops --- .../bitcoindevkit/devkitwallet/data/Kyoto.kt | 32 ++++++++++++++++--- .../viewmodels/WalletViewModel.kt | 4 +++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt index 999264c..69f6210 100644 --- a/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt +++ b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import org.bitcoindevkit.CbfBuilder import org.bitcoindevkit.CbfClient +import org.bitcoindevkit.CbfException import org.bitcoindevkit.CbfNode import org.bitcoindevkit.Info import org.bitcoindevkit.IpAddress @@ -40,10 +41,17 @@ private constructor( fun start(): Flow { kyotoNode.run() + // The client throws an Exception on the `update()` method once the node has stopped (requested shutdown, + // unreachable peers, lost connections); end the flow instead of crashing the collector return flow { - // Set this to stop under certain circumstances while (true) { - val update = kyotoClient.update() + val update = + try { + kyotoClient.update() + } catch (e: CbfException) { + Log.i(TAG, "Node has stopped, ending the updates flow: ${e.message}") + break + } emit(update) } } @@ -53,7 +61,12 @@ private constructor( val sharedFlow = MutableSharedFlow(replay = 0) scope.launch { while (true) { - val info = kyotoClient.nextInfo() + val info = + try { + kyotoClient.nextInfo() + } catch (e: CbfException) { + break + } sharedFlow.emit(info) } } @@ -64,7 +77,12 @@ private constructor( val sharedFlow = MutableSharedFlow(replay = 0) scope.launch { while (true) { - val warning = kyotoClient.nextWarning() + val warning = + try { + kyotoClient.nextWarning() + } catch (e: CbfException) { + break + } sharedFlow.emit(warning) } } @@ -101,7 +119,11 @@ private constructor( } fun shutdown() { - kyotoClient.shutdown() + try { + kyotoClient.shutdown() + } catch (e: CbfException) { + Log.i(TAG, "Shutdown requested but the node had already stopped: ${e.message}") + } } companion object { diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index f730aec..4440975 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -112,6 +112,10 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { } previousHeight = newHeight } + + // 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) } } kyoto!!.logToLogcat() }