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/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/data/Kyoto.kt b/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt index d4db8da..69f6210 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 @@ -16,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 @@ -41,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) } } @@ -54,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) } } @@ -65,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) } } @@ -102,34 +119,30 @@ 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 { + 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) - } + 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 + } - else -> { - listOf() - } - } + 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 +153,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/navigation/AppNavigation.kt b/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt index 827877f..a49ead0 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 @@ -29,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 @@ -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 { - BlockchainClientScreen( - state = walletViewModel!!.state, + val state by walletViewModel!!.state.collectAsStateWithLifecycle() + CbfNodeScreen( + state = state, onAction = walletViewModel::onAction, navController = navController, ) diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt deleted file mode 100644 index 5ad4cd4..0000000 --- a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2021-2026 thunderbiscuit and contributors. - * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. - */ - -package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings - -import androidx.compose.foundation.background -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -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( - state: WalletScreenState, - onAction: (WalletScreenAction) -> Unit, - navController: NavController, -) { - val colorScheme = MaterialTheme.colorScheme - - Scaffold( - topBar = { - SecondaryScreensAppBar( - title = "Compact Block Filters Node", - navigation = { navController.popBackStack() }, - ) - }, - containerColor = colorScheme.surface, - ) { paddingValues -> - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize().padding(paddingValues).padding(vertical = 32.dp, horizontal = 16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - val status = if (state.kyotoNodeStatus == CbfNodeStatus.Running) "Online" else "Offline" - Text( - text = "CBF Node Status: $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) - } - ) - ) - } - - 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, - ) - } - - Spacer(modifier = Modifier.padding(16.dp)) - - NeutralButton( - text = "Start Node", - enabled = state.kyotoNodeStatus == CbfNodeStatus.Stopped, - onClick = { onAction(WalletScreenAction.ActivateCbfNode) }, - ) - NeutralButton( - text = "Stop Node", - enabled = state.kyotoNodeStatus == CbfNodeStatus.Running, - onClick = { onAction(WalletScreenAction.StopKyotoNode) }, - ) - } - } -} diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt new file mode 100644 index 0000000..2fd62e6 --- /dev/null +++ b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +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 +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.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.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 CbfNodeScreen( + state: WalletScreenState, + onAction: (WalletScreenAction) -> Unit, + navController: NavController, +) { + val colorScheme = MaterialTheme.colorScheme + val isRunning = state.kyotoNodeStatus == CbfNodeStatus.Running + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Compact Block Filters Node", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding(vertical = 32.dp, horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "CBF Node Status:", + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + 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, + ) + } + + 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, + ) + } + + 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.height(32.dp)) + + Button( + onClick = { onAction(WalletScreenAction.ActivateCbfNode) }, + 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), + ) + } + } + } +} + +@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.height(16.dp)) + + val addPeerEnabled = ipInput.isNotBlank() + OutlinedButton( + onClick = { + if (NodePeer.fromInput(ipInput, portInput) != null) { + onAction(WalletScreenAction.AddCustomPeer(ipInput, portInput)) + ipInput = "" + portInput = "" + } else { + 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), + ) + } +} 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 9707299..4440975 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,21 +13,28 @@ 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 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 private const val TAG = "WalletViewModel" internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { - var state: WalletScreenState by mutableStateOf(WalletScreenState(network = 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 @@ -44,19 +48,33 @@ 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 + state.update { + if (peer in it.customPeers) it else it.copy(customPeers = it.customPeers + peer) + } + } + + private fun removeCustomPeer(peer: NodePeer) { + 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() { @@ -65,16 +83,20 @@ 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() { + // 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) + 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() @@ -84,22 +106,27 @@ 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") } 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() } private fun stopKyotoNode() { kyoto!!.shutdown() + state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Stopped) } } private fun updateBestBlock() { val bestBlockHeight = wallet.bestBlock() - state = state.copy(bestBlockHeight = bestBlockHeight) + state.update { it.copy(bestBlockHeight = bestBlockHeight) } } } 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 {