Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
- NEVER wrap methods returning `Result<T>` 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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/to/bitkit/models/NodePeer.kt
Original file line number Diff line number Diff line change
@@ -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)
206 changes: 120 additions & 86 deletions app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
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
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
)
}

Expand All @@ -105,7 +104,7 @@ fun NodeInfoScreen(
private fun Content(
lightningState: LightningState,
isRefreshing: Boolean = false,
isDevModeEnabled: Boolean,
peers: List<NodePeer> = emptyList(),
onBack: () -> Unit = {},
onRefresh: () -> Unit = {},
onDisconnectPeer: (PeerDetails) -> Unit = {},
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -390,46 +383,67 @@ private fun ChannelsSection(

@Composable
private fun PeersSection(
peers: List<PeerDetails>,
onDisconnectPeer: (PeerDetails) -> Unit,
peers: List<NodePeer>,
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,
)
}
}
}
Expand All @@ -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(
Expand Down
Loading
Loading