diff --git a/AGENTS.md b/AGENTS.md index f602de264..b3f1bcda3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - NEVER wrap methods returning `Result` in try-catch - PREFER to use `it` instead of explicit named parameters in lambdas e.g. `fn().onSuccess { log(it) }.onFailure { log(it) }` - NEVER inject ViewModels as dependencies - Only android activities and composable functions can use viewmodels +- ALWAYS co-locate screen-specific ViewModels in the same package as their screen; only place ViewModels in `viewmodels/` when shared across multiple screens - NEVER hardcode strings and always preserve string resources - ALWAYS localize in ViewModels using injected `@ApplicationContext`, e.g. `context.getString()` - ALWAYS use `remember` for expensive Compose computations diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08b67b26c..6117d6fc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,8 +55,8 @@ android { applicationId = "to.bitkit" minSdk = 28 targetSdk = 36 - versionCode = 176 - versionName = "2.0.2" + versionCode = 177 + versionName = "2.0.3" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index c4436dfd1..2a1bd8344 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -8,6 +8,7 @@ import to.bitkit.BuildConfig import to.bitkit.ext.ensureDir import to.bitkit.ext.of import to.bitkit.models.BlocktankNotificationType +import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -212,7 +213,22 @@ object Peers { val stag = PeerDetails.of("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") val lnd1 = PeerDetails.of("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") val lnd3 = PeerDetails.of("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") - val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.186.40:9735") + val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.153.174:9735") + + object Known { + val stag = NodePeer(Peers.stag, name = "Synonym-Own-Regtest-0") + val lnd1 = NodePeer(Peers.lnd1, name = "Blocktank-LND1") + val lnd3 = NodePeer(Peers.lnd3, name = "Blocktank-LND3") + val lnd4 = NodePeer(Peers.lnd4, name = "Blocktank-LND4") + + fun find(peer: PeerDetails): NodePeer? = when (peer.nodeId) { + stag.peerDetails.nodeId -> stag + lnd1.peerDetails.nodeId -> lnd1 + lnd3.peerDetails.nodeId -> lnd3 + lnd4.peerDetails.nodeId -> lnd4 + else -> null + } + } } private object ElectrumServers { diff --git a/app/src/main/java/to/bitkit/models/NodePeer.kt b/app/src/main/java/to/bitkit/models/NodePeer.kt new file mode 100644 index 000000000..584c54ecf --- /dev/null +++ b/app/src/main/java/to/bitkit/models/NodePeer.kt @@ -0,0 +1,16 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.ILspNode +import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.ext.ellipsisMiddle + +data class NodePeer( + val peerDetails: PeerDetails, + val lspNode: ILspNode? = null, + val name: String? = null, +) + +fun NodePeer.alias(): String = + lspNode?.alias + ?: name + ?: peerDetails.nodeId.ellipsisMiddle(16) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 5e81df27d..e9f40bb29 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -1,7 +1,7 @@ package to.bitkit.ui +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.fillMaxWidth @@ -9,27 +9,29 @@ 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.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RemoveCircleOutline +import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import com.synonym.bitkitcore.ILspNode import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BalanceSource import org.lightningdevkit.ldknode.BestBlock @@ -43,14 +45,18 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.balanceUiText import to.bitkit.ext.channelId import to.bitkit.ext.createChannelDetails +import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.Toast +import to.bitkit.models.NodePeer +import to.bitkit.models.alias import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.LightningChannel @@ -65,6 +71,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Shapes import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.withAccent import kotlin.time.Clock.System.now @@ -73,30 +80,22 @@ import kotlin.time.ExperimentalTime @Composable fun NodeInfoScreen( navController: NavController, + viewModel: NodeInfoViewModel = hiltViewModel(), ) { val wallet = walletViewModel ?: return - val app = appViewModel ?: return - val settings = settingsViewModel ?: return - val context = LocalContext.current val isRefreshing by wallet.isRefreshing.collectAsStateWithLifecycle() - val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + val peers by viewModel.peers.collectAsStateWithLifecycle() Content( lightningState = lightningState, + peers = peers, isRefreshing = isRefreshing, - isDevModeEnabled = isDevModeEnabled, - onBack = { navController.popBackStack() }, - onRefresh = { wallet.onPullToRefresh() }, - onDisconnectPeer = { wallet.disconnectPeer(it) }, - onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text - ) - }, + onBack = navController::popBackStack, + onRefresh = wallet::onPullToRefresh, + onDisconnectPeer = viewModel::disconnectPeer, + onCopy = viewModel::onCopy, ) } @@ -105,7 +104,7 @@ fun NodeInfoScreen( private fun Content( lightningState: LightningState, isRefreshing: Boolean = false, - isDevModeEnabled: Boolean, + peers: List = emptyList(), onBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onDisconnectPeer: (PeerDetails) -> Unit = {}, @@ -130,36 +129,30 @@ private fun Content( nodeId = lightningState.nodeId, onCopy = onCopy, ) + NodeStateSection( + nodeLifecycleState = lightningState.nodeLifecycleState, + nodeStatus = lightningState.nodeStatus, + ) + lightningState.balances?.let { details -> + WalletBalancesSection(balanceDetails = details) - if (isDevModeEnabled) { - NodeStateSection( - nodeLifecycleState = lightningState.nodeLifecycleState, - nodeStatus = lightningState.nodeStatus, - ) - - lightningState.balances?.let { details -> - WalletBalancesSection(balanceDetails = details) - - if (details.lightningBalances.isNotEmpty()) { - LightningBalancesSection(balances = details.lightningBalances) - } - } - - if (lightningState.channels.isNotEmpty()) { - ChannelsSection( - channels = lightningState.channels, - onCopy = onCopy, - ) - } - - if (lightningState.peers.isNotEmpty()) { - PeersSection( - peers = lightningState.peers, - onDisconnectPeer = onDisconnectPeer, - onCopy = onCopy, - ) + if (details.lightningBalances.isNotEmpty()) { + LightningBalancesSection(balances = details.lightningBalances) } } + if (lightningState.channels.isNotEmpty()) { + ChannelsSection( + channels = lightningState.channels, + onCopy = onCopy, + ) + } + if (peers.isNotEmpty()) { + PeersSection( + peers = peers, + onDisconnectPeer = onDisconnectPeer, + onCopy = onCopy, + ) + } VerticalSpacer(16.dp) } } @@ -390,46 +383,67 @@ private fun ChannelsSection( @Composable private fun PeersSection( - peers: List, - onDisconnectPeer: (PeerDetails) -> Unit, + peers: List, + onDisconnectPeer: (PeerDetails) -> Unit = {}, onCopy: (String) -> Unit = {}, ) { Column(modifier = Modifier.fillMaxWidth()) { SectionHeader("Peers") - peers.forEach { peer -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + peers.forEach { peer -> + PeerCard( + peer = peer, + onCopy = onCopy, + onDisconnectPeer = onDisconnectPeer, + ) + } + } + } +} + +@Composable +private fun PeerCard( + peer: NodePeer, + onCopy: (String) -> Unit, + onDisconnectPeer: (PeerDetails) -> Unit, +) { + val uri = peer.peerDetails.uri + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(onClick = copyToClipboard(uri) { onCopy(it) }) + .background(color = Colors.Gray6, shape = Shapes.medium) + .padding(16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(52.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() ) { - BodyM( - text = peer.uri, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier - .weight(1f) - .clickableAlpha( - onClick = copyToClipboard(peer.uri) { - onCopy(it) - } - ) - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .clickableAlpha(onClick = { onDisconnectPeer(peer) }) - ) { + BodyMSB(text = peer.alias()) + if (peer.lspNode != null) { Icon( - imageVector = Icons.Default.RemoveCircleOutline, - contentDescription = stringResource(R.string.common__close), - tint = Colors.Red, - modifier = Modifier.size(16.dp) + imageVector = Icons.Filled.VerifiedUser, + contentDescription = null, + tint = Colors.White32, + modifier = Modifier.size(16.dp), ) } } - HorizontalDivider() + CaptionB( + text = peer.peerDetails.nodeId.ellipsisMiddle(@Suppress("MagicNumber") 24), + color = Colors.White64, + maxLines = 1, + ) + } + IconButton(onClick = { onDisconnectPeer(peer.peerDetails) }) { + Icon( + imageVector = Icons.Default.RemoveCircleOutline, + contentDescription = stringResource(R.string.common__close), + tint = Colors.Red, + ) } } } @@ -452,27 +466,47 @@ private fun ChannelDetailRow( } } -@Preview(showSystemUi = true) +private fun previewPeers() = listOf( + NodePeer( + peerDetails = Peers.stag, + lspNode = ILspNode( + alias = "Blocktank-LND1", + pubkey = Peers.stag.nodeId, + connectionStrings = listOf(), + readonly = null, + ), + ), + NodePeer( + peerDetails = PeerDetails( + nodeId = "0448a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + address = "192.168.1.1:9735", + isConnected = true, + isPersisted = false, + ), + lspNode = null, + ), +) + +@Preview @Composable -private fun Preview() { +private fun PreviewPeersSection() { AppThemeSurface { - Content( - isDevModeEnabled = false, - lightningState = LightningState( - nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - ), - ) + Column(modifier = Modifier.padding(16.dp)) { + PeersSection( + peers = previewPeers(), + ) + } } } @OptIn(ExperimentalTime::class) @Preview(showSystemUi = true) @Composable -private fun PreviewDevMode() { +private fun Preview() { AppThemeSurface { val syncTime = now().epochSeconds.toULong() Content( - isDevModeEnabled = true, + peers = previewPeers(), lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, nodeStatus = NodeStatus( diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt new file mode 100644 index 000000000..ed8cd88f0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt @@ -0,0 +1,73 @@ +package to.bitkit.ui + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.R +import to.bitkit.env.Peers +import to.bitkit.models.NodePeer +import to.bitkit.models.Toast +import to.bitkit.models.alias +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +@HiltViewModel +class NodeInfoViewModel @Inject constructor( + @ApplicationContext private val context: Context, + blocktankRepo: BlocktankRepo, + private val lightningRepo: LightningRepo, +) : ViewModel() { + val peers: StateFlow> = combine( + lightningRepo.lightningState.map { it.peers }, + blocktankRepo.blocktankState.map { it.info?.nodes }, + ) { peers, lspNodes -> + peers.map { peer -> + NodePeer( + peerDetails = peer, + lspNode = lspNodes?.firstOrNull { it.pubkey == peer.nodeId }, + name = Peers.Known.find(peer)?.name, + ) + }.sortedBy { it.alias() } + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun disconnectPeer(peer: PeerDetails) { + viewModelScope.launch { + lightningRepo.disconnectPeer(peer) + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.common__success), + description = context.getString(R.string.wallet__peer_disconnected), + ) + } + .onFailure { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = it.message ?: context.getString(R.string.common__error_body), + ) + } + } + } + fun onCopy(text: String) = viewModelScope.launch { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.common__copied), + description = text, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/LdkDebugScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/LdkDebugScreen.kt index 7c64819a5..112dc55f5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/LdkDebugScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LdkDebugScreen.kt @@ -29,6 +29,7 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SectionFooter import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsTextButtonRow @@ -94,6 +95,7 @@ private fun LdkDebugContent( .fillMaxWidth() .padding(vertical = 8.dp), ) + VerticalSpacer(height = 4.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 84c4ea9ae..9bb7dc802 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -641,10 +641,13 @@ style: - '8' - '10' - '12' + - '16' - '20' + - '24' - '25' - '30' - '32' + - '36' - '40' - '50' - '64' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 029621f5c..5465b7fb4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.18" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.26" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 538bf73e8..d20df29ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,7 +50,14 @@ dependencyResolutionManagement { password = pass } } - maven("https://jitpack.io") + maven { + url = uri("https://maven.pkg.github.com/synonymdev/ldk-node") + credentials { + val (user, pass) = getGithubCredentials() + username = user + password = pass + } + } } } rootProject.name = "bitkit-android"