From bf4e9c48d2f06de338fbe9d56aeb88b4d54a12d0 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:15:17 +0000 Subject: [PATCH 01/11] fix: resolve channel detail black screen and navigation issues - Change Routes.ChannelDetail to accept channelId parameter - Update ChannelDetailScreen to fetch channel on initialization - Replace early return with loading state when channel is null - Navigate directly to detail screen with channelId - Remove intermediate navigation through list screen - Fix predictive back gesture by eliminating blank composable state Fixes #668 Co-authored-by: Ovi Trif --- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../settings/lightning/ChannelDetailScreen.kt | 58 +++++++++++++++---- .../lightning/LightningConnectionsScreen.kt | 16 ++--- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4c5ca717b..ce2f6ac16 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1168,9 +1168,11 @@ private fun NavGraphBuilder.lightningConnections( composableWithDefaultTransitions { val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ConnectionsNav) } val viewModel = hiltViewModel(parentEntry) + val route = it.toRoute() ChannelDetailScreen( navController = navController, viewModel = viewModel, + channelId = route.channelId, ) } composableWithDefaultTransitions { @@ -1785,7 +1787,7 @@ sealed interface Routes { data object LightningConnections : Routes @Serializable - data object ChannelDetail : Routes + data class ChannelDetail(val channelId: String) : Routes @Serializable data object CloseConnection : Routes diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 04795bd4d..7876ebdc1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -85,19 +85,42 @@ import java.util.Locale fun ChannelDetailScreen( navController: NavController, viewModel: LightningConnectionsViewModel, + channelId: String, ) { val context = LocalContext.current val app = appViewModel ?: return val wallet = walletViewModel ?: return + LaunchedEffect(channelId) { + viewModel.findAndSelectChannel(channelId) + } + val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle() - val channel = selectedChannel ?: return + val channel = selectedChannel val uiState by viewModel.uiState.collectAsStateWithLifecycle() val paidOrders by viewModel.blocktankRepo.blocktankState.collectAsStateWithLifecycle() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + + if (channel == null) { + Content( + channel = null, + blocktankOrders = emptyList(), + cjitEntries = emptyList(), + txTime = null, + isRefreshing = false, + isClosedChannel = false, + onBack = { navController.popBackStack() }, + onRefresh = {}, + onCopyText = {}, + onOpenUrl = {}, + onSupport = {}, + onCloseConnection = {}, + ) + return + } val isClosedChannel = uiState.closedChannels.any { it.details.channelId == channel.details.channelId } - val lightningState by wallet.lightningState.collectAsStateWithLifecycle() // Fetch transaction details for funding transaction if available LaunchedEffect(channel.details.fundingTxo?.txid) { @@ -108,8 +131,8 @@ fun ChannelDetailScreen( // Fetch activity timestamp for transfer activity with matching channel ID LaunchedEffect(channel.details.channelId) { - channel.details.channelId?.let { channelId -> - viewModel.fetchActivityTimestamp(channelId) + channel.details.channelId.let { id -> + viewModel.fetchActivityTimestamp(id) } } @@ -148,7 +171,7 @@ fun ChannelDetailScreen( @Suppress("CyclomaticComplexMethod") @Composable private fun Content( - channel: ChannelUi, + channel: ChannelUi?, blocktankOrders: List = emptyList(), cjitEntries: List = emptyList(), txTime: ULong? = null, @@ -161,6 +184,24 @@ private fun Content( onSupport: (Any) -> Unit = {}, onCloseConnection: () -> Unit = {}, ) { + ScreenColumn { + AppTopBar( + titleText = channel?.name ?: "", + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + if (channel == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CaptionB(text = stringResource(R.string.common__loading)) + } + return@ScreenColumn + } // Check if the channel was opened via CJIT val cjitEntry = cjitEntries.find { entry -> entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid @@ -184,13 +225,6 @@ private fun Content( val remoteBalance = (channel.details.inboundCapacityMsat / 1000u).toLong() val reserveBalance = (channel.details.unspendablePunishmentReserve ?: 0u).toLong() - ScreenColumn { - AppTopBar( - titleText = channel.name, - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = onRefresh, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index f9611f6cc..857b1ac3c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -88,18 +88,13 @@ fun LightningConnectionsScreen( viewModel.refreshObservedState() viewModel.clearSelectedChannel() viewModel.clearTransactionDetails() - } - LaunchedEffect(navController.currentBackStackEntry) { val selectedChannelId = navController.previousBackStackEntry?.savedStateHandle?.get("selectedChannelId") - if (selectedChannelId == null) return@LaunchedEffect - - navController.previousBackStackEntry?.savedStateHandle?.remove("selectedChannelId") - delay(CHANNEL_SELECTION_DELAY_MS) - if (viewModel.findAndSelectChannel(selectedChannelId)) { - navController.navigate(Routes.ChannelDetail) { + if (selectedChannelId != null) { + navController.previousBackStackEntry?.savedStateHandle?.remove("selectedChannelId") + delay(CHANNEL_SELECTION_DELAY_MS) + navController.navigate(Routes.ChannelDetail(selectedChannelId)) { launchSingleTop = true - popUpTo(Routes.ConnectionsNav) { inclusive = false } } } } @@ -112,8 +107,7 @@ fun LightningConnectionsScreen( viewModel.zipLogsForSharing { uri -> context.shareZipFile(uri) } }, onClickChannel = { channelUi -> - viewModel.setSelectedChannel(channelUi) - navController.navigate(Routes.ChannelDetail) + navController.navigate(Routes.ChannelDetail(channelUi.details.channelId)) }, onRefresh = { viewModel.onPullToRefresh() From bef2790ec2f39be5f7c43966abe3231c3462660b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Jan 2026 23:47:27 +0100 Subject: [PATCH 02/11] chore: add compose preview for channel loading --- .../settings/lightning/ChannelDetailScreen.kt | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 7876ebdc1..02b98fcb2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -105,17 +106,7 @@ fun ChannelDetailScreen( if (channel == null) { Content( channel = null, - blocktankOrders = emptyList(), - cjitEntries = emptyList(), - txTime = null, - isRefreshing = false, - isClosedChannel = false, onBack = { navController.popBackStack() }, - onRefresh = {}, - onCopyText = {}, - onOpenUrl = {}, - onSupport = {}, - onCloseConnection = {}, ) return } @@ -193,37 +184,36 @@ private fun Content( if (channel == null) { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), ) { - CaptionB(text = stringResource(R.string.common__loading)) + CircularProgressIndicator() } return@ScreenColumn } - // Check if the channel was opened via CJIT - val cjitEntry = cjitEntries.find { entry -> - entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid - } - // Check if the channel was opened via blocktank order - val blocktankOrder = blocktankOrders.find { order -> - // real channel - if (channel.details.fundingTxo?.txid != null) { - order.channel?.fundingTx?.id == channel.details.fundingTxo?.txid - } else { - // fake channel - order.id == channel.details.channelId + // Check if the channel was opened via CJIT + val cjitEntry = cjitEntries.find { entry -> + entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid + } + + // Check if the channel was opened via blocktank order + val blocktankOrder = blocktankOrders.find { order -> + // real channel + if (channel.details.fundingTxo?.txid != null) { + order.channel?.fundingTx?.id == channel.details.fundingTxo?.txid + } else { + // fake channel + order.id == channel.details.channelId + } } - } - val order = blocktankOrder ?: cjitEntry + val order = blocktankOrder ?: cjitEntry - val capacity = channel.details.channelValueSats.toLong() - val localBalance = channel.details.amountOnClose.toLong() - val remoteBalance = (channel.details.inboundCapacityMsat / 1000u).toLong() - val reserveBalance = (channel.details.unspendablePunishmentReserve ?: 0u).toLong() + val capacity = channel.details.channelValueSats.toLong() + val localBalance = channel.details.amountOnClose.toLong() + val remoteBalance = (channel.details.inboundCapacityMsat / 1000u).toLong() + val reserveBalance = (channel.details.unspendablePunishmentReserve ?: 0u).toLong() PullToRefreshBox( isRefreshing = isRefreshing, @@ -662,6 +652,14 @@ private fun createSupportEmailIntent( return Intent(Intent.ACTION_SENDTO, uri) } +@Preview +@Composable +private fun PreviewLoadingState() { + AppThemeSurface { + Content(channel = null) + } +} + @Preview @Composable private fun PreviewOpenChannel() { From 40019ff53f9a91010b0453e918ab00d91a50ed7e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Mar 2026 22:56:26 +0100 Subject: [PATCH 03/11] refactor: extract channel details vm --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 +- .../settings/lightning/ChannelDetailScreen.kt | 140 +++--- .../lightning/ChannelDetailViewModel.kt | 199 +++++++++ .../lightning/CloseConnectionScreen.kt | 3 +- .../lightning/LightningConnectionsScreen.kt | 2 - .../LightningConnectionsViewModel.kt | 404 ++++++------------ app/src/main/res/values/strings.xml | 1 + 7 files changed, 387 insertions(+), 370 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ce2f6ac16..020ffd3f4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1166,21 +1166,21 @@ private fun NavGraphBuilder.lightningConnections( LightningConnectionsScreen(navController, viewModel) } composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ConnectionsNav) } - val viewModel = hiltViewModel(parentEntry) val route = it.toRoute() ChannelDetailScreen( navController = navController, - viewModel = viewModel, + viewModel = hiltViewModel(), channelId = route.channelId, ) } composableWithDefaultTransitions { val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ConnectionsNav) } val viewModel = hiltViewModel(parentEntry) + val route = it.toRoute() CloseConnectionScreen( navController = navController, viewModel = viewModel, + channelId = route.channelId, ) } } @@ -1790,7 +1790,7 @@ sealed interface Routes { data class ChannelDetail(val channelId: String) : Routes @Serializable - data object CloseConnection : Routes + data class CloseConnection(val channelId: String) : Routes @Serializable data object DevSettings : Routes diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 02b98fcb2..ceadd31d2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -76,7 +76,6 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.getBlockExplorerUrl -import to.bitkit.ui.walletViewModel import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -85,84 +84,83 @@ import java.util.Locale @Composable fun ChannelDetailScreen( navController: NavController, - viewModel: LightningConnectionsViewModel, + viewModel: ChannelDetailViewModel, channelId: String, ) { val context = LocalContext.current val app = appViewModel ?: return - val wallet = walletViewModel ?: return LaunchedEffect(channelId) { - viewModel.findAndSelectChannel(channelId) + viewModel.loadChannel(channelId) } - val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle() - val channel = selectedChannel - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val paidOrders by viewModel.blocktankRepo.blocktankState.collectAsStateWithLifecycle() - val lightningState by wallet.lightningState.collectAsStateWithLifecycle() - - if (channel == null) { - Content( - channel = null, - onBack = { navController.popBackStack() }, - ) - return - } - - val isClosedChannel = uiState.closedChannels.any { it.details.channelId == channel.details.channelId } - // Fetch transaction details for funding transaction if available - LaunchedEffect(channel.details.fundingTxo?.txid) { - channel.details.fundingTxo?.txid?.let { txid -> - viewModel.fetchTransactionDetails(txid) + when (val loadState = uiState.channelLoadState) { + is ChannelLoadState.Loading -> { + ScreenColumn { + AppTopBar( + titleText = "", + onBackClick = { navController.popBackStack() }, + actions = { DrawerNavIcon() }, + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } + } } - } - // Fetch activity timestamp for transfer activity with matching channel ID - LaunchedEffect(channel.details.channelId) { - channel.details.channelId.let { id -> - viewModel.fetchActivityTimestamp(id) + is ChannelLoadState.NotFound -> { + LaunchedEffect(Unit) { + app.toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.lightning__channel_not_found), + ) + navController.popBackStack() + } } - } - val txTime by viewModel.txTime.collectAsStateWithLifecycle() - - Content( - channel = channel, - blocktankOrders = paidOrders.paidOrders, - cjitEntries = paidOrders.cjitEntries, - txTime = txTime, - isRefreshing = uiState.isRefreshing, - isClosedChannel = isClosedChannel, - onBack = { navController.popBackStack() }, - onRefresh = { - viewModel.onPullToRefresh() - }, - onCopyText = { text -> - context.setClipboardText(text) - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text, + is ChannelLoadState.Success -> { + val channel = loadState.channel + Content( + channel = channel, + blocktankOrders = uiState.paidOrders, + cjitEntries = uiState.cjitEntries, + txTime = uiState.txTime, + isRefreshing = uiState.isRefreshing, + isClosedChannel = uiState.isClosedChannel, + onBack = { navController.popBackStack() }, + onRefresh = { viewModel.onPullToRefresh() }, + onCopyText = { text -> + context.setClipboardText(text) + app.toast( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.common__copied), + description = text, + ) + }, + onOpenUrl = { txId -> + val url = getBlockExplorerUrl(txId) + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + }, + onSupport = { order -> contactSupport(order, channel, context) }, + onCloseConnection = { + navController.navigate(Routes.CloseConnection(channelId = channel.details.channelId)) + }, ) - }, - onOpenUrl = { txId -> - val url = getBlockExplorerUrl(txId) - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - context.startActivity(intent) - }, - onSupport = { order -> contactSupport(order, channel, lightningState.nodeId, context) }, - onCloseConnection = { navController.navigate(Routes.CloseConnection) }, - ) + } + } } @OptIn(ExperimentalMaterial3Api::class) @Suppress("CyclomaticComplexMethod") @Composable private fun Content( - channel: ChannelUi?, + channel: ChannelUi, blocktankOrders: List = emptyList(), cjitEntries: List = emptyList(), txTime: ULong? = null, @@ -177,21 +175,11 @@ private fun Content( ) { ScreenColumn { AppTopBar( - titleText = channel?.name ?: "", + titleText = channel.name, onBackClick = onBack, actions = { DrawerNavIcon() }, ) - if (channel == null) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), - ) { - CircularProgressIndicator() - } - return@ScreenColumn - } - // Check if the channel was opened via CJIT val cjitEntry = cjitEntries.find { entry -> entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid @@ -405,8 +393,6 @@ private fun Content( } ) - val fundingTxId = channel.details.fundingTxo?.txid - txTime?.let { SectionRow( name = stringResource(R.string.lightning__opened_on), @@ -601,13 +587,11 @@ private fun formatUnixTimestamp(timestamp: Long): String { private fun contactSupport( order: Any, channel: ChannelUi, - nodeId: String, context: Context, ) { val intent = createSupportEmailIntent( order = order, channel = channel, - nodeId = nodeId, ) runCatching { context.startActivity(Intent.createChooser(intent, context.getString(R.string.lightning__support))) @@ -620,7 +604,6 @@ private fun contactSupport( private fun createSupportEmailIntent( order: Any, // IBtOrder or IcJitEntry channel: ChannelUi, - nodeId: String, ): Intent { val subject = "Bitkit Support [Channel]" @@ -644,7 +627,6 @@ private fun createSupportEmailIntent( appendLine() appendLine("Platform: ${Env.platform}") appendLine("Version: ${Env.version}") - appendLine("LDK node ID: $nodeId") }.trim() val uri = "mailto:${Env.SUPPORT_EMAIL}?subject=${Uri.encode(subject)}&body=${Uri.encode(body)}".toUri() @@ -652,14 +634,6 @@ private fun createSupportEmailIntent( return Intent(Intent.ACTION_SENDTO, uri) } -@Preview -@Composable -private fun PreviewLoadingState() { - AppThemeSurface { - Content(channel = null) - } -} - @Preview @Composable private fun PreviewOpenChannel() { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt new file mode 100644 index 000000000..d7ae726d4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -0,0 +1,199 @@ +package to.bitkit.ui.settings.lightning + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.IcJitEntry +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.SortDirection +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.OutPoint +import to.bitkit.R +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.createChannelDetails +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import javax.inject.Inject + +@Suppress("LongParameterList", "TooManyFunctions") +@HiltViewModel +class ChannelDetailViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ChannelDetailUiState()) + val uiState = _uiState.asStateFlow() + + fun loadChannel(channelId: String) { + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(channelLoadState = ChannelLoadState.Loading) } + + val closedChannels = loadClosedChannels() + val channelUi = findChannelUi(channelId, closedChannels) + + if (channelUi == null) { + _uiState.update { it.copy(channelLoadState = ChannelLoadState.NotFound) } + return@launch + } + + val isClosedChannel = closedChannels.any { it.details.channelId == channelId } + + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(channelUi), + paidOrders = blocktankRepo.blocktankState.value.paidOrders, + cjitEntries = blocktankRepo.blocktankState.value.cjitEntries, + isClosedChannel = isClosedChannel, + ) + } + + fetchActivityTimestamp(channelId) + observeChannelUpdates(channelId, closedChannels) + } + } + + fun onPullToRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + lightningRepo.sync() + blocktankRepo.refreshOrders() + delay(500) + _uiState.update { it.copy(isRefreshing = false) } + } + } + + private fun findChannelUi(channelId: String, closedChannels: List): ChannelUi? { + val channels = lightningRepo.lightningState.value.channels + val blocktankState = blocktankRepo.blocktankState.value + val connectionText = context.getString(R.string.lightning__connection) + + return channels.find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ?: getFailedOrdersAsChannels(blocktankState.paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ?: closedChannels.find { it.details.channelId == channelId } + ?: blocktankState.orders.find { it.id == channelId }?.let { order -> + createChannelDetails().copy( + channelId = order.id, + counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), + fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, + channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + outboundCapacityMsat = order.clientBalanceSat * 1000u, + inboundCapacityMsat = order.lspBalanceSat * 1000u, + ).mapToUiModel(channels, blocktankState.paidOrders, connectionText) + } + } + + private suspend fun loadClosedChannels(): List { + val connectionText = context.getString(R.string.lightning__connection) + val channels = lightningRepo.lightningState.value.channels + val paidOrders = blocktankRepo.blocktankState.value.paidOrders + + return activityRepo.getClosedChannels(SortDirection.DESC) + .getOrNull() + ?.mapIndexed { index, closedChannel -> + closedChannel.toChannelUi( + baseIndex = channels.size + getPendingOrdersAsChannels(channels, paidOrders).size + index, + connectionText = connectionText, + ) + } + ?.reversed() + .orEmpty() + } + + private fun fetchActivityTimestamp(channelId: String) { + viewModelScope.launch(bgDispatcher) { + val activities = activityRepo.getActivities( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ).getOrNull().orEmpty() + + val transferActivity = activities.firstOrNull { activity -> + activity is Activity.Onchain && + activity.v1.isTransfer && + activity.v1.channelId == channelId + } as? Activity.Onchain + + _uiState.update { + it.copy(txTime = transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp) + } + } + } + + private fun observeChannelUpdates(channelId: String, closedChannels: List) { + viewModelScope.launch(bgDispatcher) { + combine( + lightningRepo.lightningState, + blocktankRepo.blocktankState, + ) { lightningState, blocktankState -> + val channels = lightningState.channels + val connectionText = context.getString(R.string.lightning__connection) + + val updatedChannel = channels.find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ?: getFailedOrdersAsChannels(blocktankState.paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + + val isClosedChannel = closedChannels.any { it.details.channelId == channelId } + + Triple(updatedChannel, blocktankState, isClosedChannel) + }.collect { (updatedChannel, blocktankState, isClosedChannel) -> + if (updatedChannel != null) { + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(updatedChannel), + paidOrders = blocktankState.paidOrders, + cjitEntries = blocktankState.cjitEntries, + isClosedChannel = isClosedChannel, + ) + } + } + } + } + } +} + +data class ChannelDetailUiState( + val channelLoadState: ChannelLoadState = ChannelLoadState.Loading, + val paidOrders: List = emptyList(), + val cjitEntries: List = emptyList(), + val txTime: ULong? = null, + val isRefreshing: Boolean = false, + val isClosedChannel: Boolean = false, +) + +sealed interface ChannelLoadState { + data object Loading : ChannelLoadState + data class Success(val channel: ChannelUi) : ChannelLoadState + data object NotFound : ChannelLoadState +} diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt index f31de0ea5..8112d748c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt @@ -40,6 +40,7 @@ import to.bitkit.ui.utils.withAccentBoldBright fun CloseConnectionScreen( navController: NavController, viewModel: LightningConnectionsViewModel, + channelId: String, ) { val uiState by viewModel.closeConnectionUiState.collectAsState() @@ -58,7 +59,7 @@ fun CloseConnectionScreen( Content( isLoading = uiState.isLoading, onBack = { navController.popBackStack() }, - onClickClose = { viewModel.closeChannel() }, + onClickClose = { viewModel.closeChannel(channelId) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 857b1ac3c..68931c33c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -86,8 +86,6 @@ fun LightningConnectionsScreen( LaunchedEffect(Unit) { viewModel.refreshObservedState() - viewModel.clearSelectedChannel() - viewModel.clearTransactionDetails() val selectedChannelId = navController.previousBackStackEntry?.savedStateHandle?.get("selectedChannelId") if (selectedChannelId != null) { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 409cc4887..9e0230c6e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -4,14 +4,10 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtOrder -import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection -import com.synonym.bitkitcore.TransactionDetails import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -49,7 +45,7 @@ class LightningConnectionsViewModel @Inject constructor( @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, - internal val blocktankRepo: BlocktankRepo, + private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, @@ -59,15 +55,6 @@ class LightningConnectionsViewModel @Inject constructor( private val _uiState = MutableStateFlow(LightningConnectionsUiState()) val uiState = _uiState.asStateFlow() - private val _selectedChannel = MutableStateFlow(null) - val selectedChannel = _selectedChannel.asStateFlow() - - private val _txDetails = MutableStateFlow(null) - val txDetails = _txDetails.asStateFlow() - - private val _txTime = MutableStateFlow(null) - val txTime = _txTime.asStateFlow() - private val _closeConnectionUiState = MutableStateFlow(CloseConnectionUiState()) val closeConnectionUiState = _closeConnectionUiState.asStateFlow() @@ -90,7 +77,8 @@ class LightningConnectionsViewModel @Inject constructor( state.copy( closedChannels = closedChannels.mapIndexed { index, closedChannel -> closedChannel.toChannelUi( - baseIndex = openChannels.size + pendingConnections.size + index + baseIndex = openChannels.size + pendingConnections.size + index, + connectionText = context.getString(R.string.lightning__connection), ) }.reversed() ) @@ -110,20 +98,22 @@ class LightningConnectionsViewModel @Inject constructor( ) { lightningState, blocktankState -> val channels = lightningState.channels val isNodeRunning = lightningState.nodeLifecycleState.isRunning() - val openChannels = channels.filterOpen() + val connectionText = context.getString(R.string.lightning__connection) _uiState.value.copy( isNodeRunning = isNodeRunning, - openChannels = openChannels.map { channel -> channel.mapToUiModel() }, + openChannels = channels.filterOpen().map { channel -> + channel.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + }, pendingConnections = getPendingConnections(channels, blocktankState.paidOrders) - .map { it.mapToUiModel() }, - failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders).map { it.mapToUiModel() }, + .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, + failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) + .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, localBalance = calculateLocalBalance(channels), remoteBalance = channels.calculateRemoteBalance(), ) }.collect { newState -> _uiState.update { newState } - refreshSelectedChannelIfNeeded(lightningRepo.lightningState.value.channels) } } } @@ -142,70 +132,6 @@ class LightningConnectionsViewModel @Inject constructor( } } - private fun refreshSelectedChannelIfNeeded(channels: List) { - val currentSelectedChannel = _selectedChannel.value ?: return - - // Filter out closed channels from the list - val closedChannelIds = _uiState.value.closedChannels.map { it.details.channelId }.toSet() - val activeChannels = channels.filterNot { it.channelId in closedChannelIds } - - // Don't refresh if the selected channel is closed - if (currentSelectedChannel.details.channelId in closedChannelIds) { - return - } - - // Try to find updated version in active channels - val updatedChannel = findUpdatedChannel(currentSelectedChannel.details, activeChannels) - _selectedChannel.update { updatedChannel?.mapToUiModel() } - } - - @Suppress("ReturnCount") - private fun findUpdatedChannel( - currentChannel: ChannelDetails, - allChannels: List, - ): ChannelDetails? { - allChannels.find { it.channelId == currentChannel.channelId }?.let { return it } - - // If current channel has funding txo, try to match by it - currentChannel.fundingTxo?.let { fundingTxo -> - allChannels - .find { it.fundingTxo?.txid == fundingTxo.txid && it.fundingTxo?.vout == fundingTxo.vout } - ?.let { return it } - } - - // Try to find in pending/failed order channels - val blocktankState = blocktankRepo.blocktankState.value - val pendingOrderChannels = getPendingOrdersAsChannels( - allChannels, - blocktankState.paidOrders, - ) - val failedOrderChannels = getFailedOrdersAsChannels( - blocktankState.paidOrders, - ) - val orderChannels = pendingOrderChannels + failedOrderChannels - - // Direct channel ID match in order channels - orderChannels.find { it.channelId == currentChannel.channelId }?.let { return it } - - // If the current channel was a fake channel (order), check if it became a real channel - val orders = blocktankRepo.blocktankState.value.orders - val orderForCurrentChannel = orders.find { it.id == currentChannel.channelId } - - if (orderForCurrentChannel != null) { - // Check if order now has a funding tx - val fundingTxId = orderForCurrentChannel.channel?.fundingTx?.id - if (fundingTxId != null) { - // Try to find real channel with matching funding tx - allChannels.find { channel -> channel.fundingTxo?.txid == fundingTxId }?.let { return it } - } - - // Order might have transitioned states, check if it's still in our fake channels - orderChannels.find { it.channelId == orderForCurrentChannel.id }?.let { return it } - } - - return null - } - fun onPullToRefresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } @@ -221,64 +147,6 @@ class LightningConnectionsViewModel @Inject constructor( loadClosedChannels() } - private fun ClosedChannelDetails.toChannelUi(baseIndex: Int): ChannelUi { - val channelDetails = createChannelDetails().copy( - channelId = this.channelId, - counterpartyNodeId = this.counterpartyNodeId, - fundingTxo = OutPoint(txid = this.fundingTxoTxid, vout = this.fundingTxoIndex), - channelValueSats = this.channelValueSats, - outboundCapacityMsat = this.outboundCapacityMsat, - inboundCapacityMsat = this.inboundCapacityMsat, - unspendablePunishmentReserve = this.unspendablePunishmentReserve, - counterpartyUnspendablePunishmentReserve = this.counterpartyUnspendablePunishmentReserve, - isChannelReady = false, - isUsable = false, - ) - val connectionText = context.getString(R.string.lightning__connection) - return ChannelUi( - name = "$connectionText ${baseIndex + 1}", - details = channelDetails, - closureReason = this.channelClosureReason.takeIf { it.isNotEmpty() } - ) - } - - private fun ChannelDetails.mapToUiModel(): ChannelUi = ChannelUi( - name = getChannelName(this), - details = this - ) - - @Suppress("ForbiddenComment") - private fun getChannelName(channel: ChannelDetails): String { - val default = channel.inboundScidAlias?.toString() ?: "${channel.channelId.take(10)}…" - - val channels = lightningRepo.lightningState.value.channels - val paidBlocktankOrders = blocktankRepo.blocktankState.value.paidOrders - - // orders without a corresponding known channel are considered pending - val pendingChannels = paidBlocktankOrders.filter { order -> - channels.none { channel -> channel.fundingTxo?.txid == order.channel?.fundingTx?.id } - } - val pendingIndex = pendingChannels.indexOfFirst { order -> channel.channelId == order.id } - - // TODO: sort channels to get consistent index; node.listChannels returns a list in random order - val channelIndex = channels.indexOfFirst { channel.channelId == it.channelId } - - val connectionText = context.getString(R.string.lightning__connection) - - return when { - channelIndex == -1 -> { - if (pendingIndex == -1) { - default - } else { - val index = channels.size + pendingIndex - "$connectionText ${index + 1}" - } - } - - else -> "$connectionText ${channelIndex + 1}" - } - } - private fun getPendingConnections( knownChannels: List, paidOrders: List, @@ -289,48 +157,6 @@ class LightningConnectionsViewModel @Inject constructor( return pendingOrderChannels + pendingLdkChannels } - private fun getPendingOrdersAsChannels( - knownChannels: List, - paidOrders: List, - ): List { - return paidOrders.mapNotNull { order -> - // Only process orders that don't have a corresponding known channel - if (knownChannels.any { channel -> channel.fundingTxo?.txid == order.channel?.fundingTx?.id }) { - return@mapNotNull null - } - - if (order.state2 !in listOf(BtOrderState2.CREATED, BtOrderState2.PAID)) return@mapNotNull null - - createChannelDetails().copy( - channelId = order.id, - counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), - fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, - outboundCapacityMsat = order.clientBalanceSat * 1000u, - inboundCapacityMsat = order.lspBalanceSat * 1000u, - ) - } - } - - private fun getFailedOrdersAsChannels( - paidOrders: List, - ): List { - return paidOrders.mapNotNull { order -> - if (order.state2 != BtOrderState2.EXPIRED) return@mapNotNull null - - createChannelDetails().copy( - channelId = order.id, - counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), - fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, - outboundCapacityMsat = order.clientBalanceSat * 1000u, - inboundCapacityMsat = order.lspBalanceSat * 1000u, - isChannelReady = false, - isUsable = false, - ) - } - } - private fun calculateLocalBalance(channels: List): ULong { return channels .filterOpen() @@ -341,7 +167,7 @@ class LightningConnectionsViewModel @Inject constructor( viewModelScope.launch { logsRepo.zipLogsForSharing() .onSuccess { uri -> onReady(uri) } - .onFailure { err -> + .onFailure { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.lightning__error_logs), @@ -351,104 +177,17 @@ class LightningConnectionsViewModel @Inject constructor( } } - fun setSelectedChannel(channelUi: ChannelUi) { - _selectedChannel.update { channelUi } - } - - fun clearSelectedChannel() = _selectedChannel.update { null } - - fun findAndSelectChannel(channelId: String): Boolean { - val channels = lightningRepo.lightningState.value.channels - val blocktankState = blocktankRepo.blocktankState.value - - val channelUi = findChannelUi(channelId, channels, blocktankState) - if (channelUi != null) { - setSelectedChannel(channelUi) - return true - } - - return false - } - - private fun findChannelUi( - channelId: String, - channels: List, - blocktankState: to.bitkit.repositories.BlocktankState, - ): ChannelUi? { - return channels.find { it.channelId == channelId }?.mapToUiModel() - ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) - .find { it.channelId == channelId }?.mapToUiModel() - ?: getFailedOrdersAsChannels(blocktankState.paidOrders) - .find { it.channelId == channelId }?.mapToUiModel() - ?: _uiState.value.closedChannels.find { it.details.channelId == channelId } - ?: blocktankState.orders.find { it.id == channelId }?.let { order -> - createChannelDetails().copy( - channelId = order.id, - counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), - fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, - outboundCapacityMsat = order.clientBalanceSat * 1000u, - inboundCapacityMsat = order.lspBalanceSat * 1000u, - ).mapToUiModel() - } - } - - fun fetchTransactionDetails(txid: String) { - viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) - .onSuccess { transactionDetails -> - _txDetails.update { transactionDetails } - if (transactionDetails != null) { - Logger.debug("fetchTransactionDetails success for: '$txid'", context = TAG) - } else { - Logger.warn("Transaction details not found for: '$txid'", context = TAG) - } - } - .onFailure { e -> - Logger.warn("fetchTransactionDetails error for: '$txid'", e, context = TAG) - _txDetails.update { null } - } - } - } - - fun clearTransactionDetails() { - _txDetails.update { null } - _txTime.update { null } - } - - fun fetchActivityTimestamp(channelId: String) { - viewModelScope.launch { - val activities = activityRepo.getActivities( - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null - ).getOrNull() ?: emptyList() - - val transferActivity = activities.firstOrNull { activity -> - activity is Activity.Onchain && - activity.v1.isTransfer && - activity.v1.channelId == channelId - } as? Activity.Onchain - - _txTime.update { transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp } - } - } - fun clearCloseConnectionState() { _closeConnectionUiState.update { CloseConnectionUiState() } } - fun closeChannel() { - val channel = _selectedChannel.value?.details ?: run { - val error = IllegalStateException("No channel selected for closing") - Logger.error(error.message, e = error, context = TAG) - throw error - } + fun closeChannel(channelId: String) { + val channel = lightningRepo.lightningState.value.channels + .find { it.channelId == channelId } + ?: run { + Logger.error("No channel found for closing: $channelId", context = TAG) + return + } viewModelScope.launch { _closeConnectionUiState.update { it.copy(isLoading = true) } @@ -477,7 +216,7 @@ class LightningConnectionsViewModel @Inject constructor( } }, onFailure = { error -> - Logger.error("Failed to close channel", e = error, context = TAG) + Logger.error("Failed to close channel", error, context = TAG) ToastEventBus.send( type = Toast.ToastType.WARNING, @@ -496,6 +235,111 @@ class LightningConnectionsViewModel @Inject constructor( } } +internal fun getPendingOrdersAsChannels( + knownChannels: List, + paidOrders: List, +): List { + return paidOrders.mapNotNull { order -> + if (knownChannels.any { channel -> channel.fundingTxo?.txid == order.channel?.fundingTx?.id }) { + return@mapNotNull null + } + + if (order.state2 !in listOf(BtOrderState2.CREATED, BtOrderState2.PAID)) return@mapNotNull null + + createChannelDetails().copy( + channelId = order.id, + counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), + fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, + channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + outboundCapacityMsat = order.clientBalanceSat * 1000u, + inboundCapacityMsat = order.lspBalanceSat * 1000u, + ) + } +} + +internal fun getFailedOrdersAsChannels( + paidOrders: List, +): List { + return paidOrders.mapNotNull { order -> + if (order.state2 != BtOrderState2.EXPIRED) return@mapNotNull null + + createChannelDetails().copy( + channelId = order.id, + counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), + fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, + channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + outboundCapacityMsat = order.clientBalanceSat * 1000u, + inboundCapacityMsat = order.lspBalanceSat * 1000u, + isChannelReady = false, + isUsable = false, + ) + } +} + +@Suppress("ForbiddenComment") +internal fun getChannelName( + channel: ChannelDetails, + allChannels: List, + paidOrders: List, + connectionText: String, +): String { + val default = channel.inboundScidAlias?.toString() ?: "${channel.channelId.take(10)}…" + + // orders without a corresponding known channel are considered pending + val pendingChannels = paidOrders.filter { order -> + allChannels.none { ch -> ch.fundingTxo?.txid == order.channel?.fundingTx?.id } + } + val pendingIndex = pendingChannels.indexOfFirst { order -> channel.channelId == order.id } + + // TODO: sort channels to get consistent index; node.listChannels returns a list in random order + val channelIndex = allChannels.indexOfFirst { channel.channelId == it.channelId } + + return when { + channelIndex == -1 -> { + if (pendingIndex == -1) { + default + } else { + val index = allChannels.size + pendingIndex + "$connectionText ${index + 1}" + } + } + + else -> "$connectionText ${channelIndex + 1}" + } +} + +internal fun ChannelDetails.mapToUiModel( + allChannels: List, + paidOrders: List, + connectionText: String, +): ChannelUi = ChannelUi( + name = getChannelName(this, allChannels, paidOrders, connectionText), + details = this, +) + +internal fun ClosedChannelDetails.toChannelUi( + baseIndex: Int, + connectionText: String, +): ChannelUi { + val channelDetails = createChannelDetails().copy( + channelId = this.channelId, + counterpartyNodeId = this.counterpartyNodeId, + fundingTxo = OutPoint(txid = this.fundingTxoTxid, vout = this.fundingTxoIndex), + channelValueSats = this.channelValueSats, + outboundCapacityMsat = this.outboundCapacityMsat, + inboundCapacityMsat = this.inboundCapacityMsat, + unspendablePunishmentReserve = this.unspendablePunishmentReserve, + counterpartyUnspendablePunishmentReserve = this.counterpartyUnspendablePunishmentReserve, + isChannelReady = false, + isUsable = false, + ) + return ChannelUi( + name = "$connectionText ${baseIndex + 1}", + details = channelDetails, + closureReason = this.channelClosureReason.takeIf { it.isNotEmpty() } + ) +} + data class LightningConnectionsUiState( val isNodeRunning: Boolean = true, val isRefreshing: Boolean = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2128d6c51..be7dc022d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,7 @@ Spending base fee Block Height Channel ID + Channel not found Peer ID You can now pay anyone, anywhere, instantly. Spending Balance Ready From f0f217a5604bcf0b09e294312de1cbe87e991155 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Mar 2026 23:32:03 +0100 Subject: [PATCH 04/11] refactor: extract close channel vm --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 +- .../settings/lightning/ChannelDetailScreen.kt | 5 +- .../lightning/CloseConnectionScreen.kt | 12 +-- .../lightning/CloseConnectionViewModel.kt | 91 +++++++++++++++++++ .../LightningConnectionsViewModel.kt | 67 -------------- 5 files changed, 100 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 020ffd3f4..349e1469b 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1168,19 +1168,15 @@ private fun NavGraphBuilder.lightningConnections( composableWithDefaultTransitions { val route = it.toRoute() ChannelDetailScreen( - navController = navController, - viewModel = hiltViewModel(), channelId = route.channelId, + navController = navController, ) } composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ConnectionsNav) } - val viewModel = hiltViewModel(parentEntry) val route = it.toRoute() CloseConnectionScreen( - navController = navController, - viewModel = viewModel, channelId = route.channelId, + navController = navController, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index ceadd31d2..c3453ff12 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.synonym.bitkitcore.BtBolt11InvoiceState @@ -83,9 +84,9 @@ import java.util.Locale @Composable fun ChannelDetailScreen( - navController: NavController, - viewModel: ChannelDetailViewModel, channelId: String, + navController: NavController, + viewModel: ChannelDetailViewModel= hiltViewModel(), ) { val context = LocalContext.current val app = appViewModel ?: return diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt index 8112d748c..039db65a0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.Routes @@ -38,16 +39,11 @@ import to.bitkit.ui.utils.withAccentBoldBright @Composable fun CloseConnectionScreen( - navController: NavController, - viewModel: LightningConnectionsViewModel, channelId: String, + navController: NavController, + viewModel: CloseConnectionViewModel= hiltViewModel(), ) { - val uiState by viewModel.closeConnectionUiState.collectAsState() - - // Reset state when entering the screen - LaunchedEffect(Unit) { - viewModel.clearCloseConnectionState() - } + val uiState by viewModel.uiState.collectAsState() // Handle success navigation LaunchedEffect(uiState.closeSuccess) { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt new file mode 100644 index 000000000..857edc129 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt @@ -0,0 +1,91 @@ +package to.bitkit.ui.settings.lightning + +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.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.amountOnClose +import to.bitkit.models.Toast +import to.bitkit.models.TransferType +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class CloseConnectionViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val lightningRepo: LightningRepo, + private val walletRepo: WalletRepo, + private val transferRepo: TransferRepo, +) : ViewModel() { + + companion object { + private const val TAG = "CloseConnectionViewModel" + } + + private val _uiState = MutableStateFlow(CloseConnectionUiState()) + val uiState = _uiState.asStateFlow() + + fun closeChannel(channelId: String) { + val channel = lightningRepo.lightningState.value.channels + .find { it.channelId == channelId } + ?: run { + Logger.error("No channel found for closing: '$channelId'", context = TAG) + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + lightningRepo.closeChannel(channel).fold( + onSuccess = { + transferRepo.createTransfer( + type = TransferType.COOP_CLOSE, + amountSats = channel.amountOnClose.toLong(), + channelId = channel.channelId, + fundingTxId = channel.fundingTxo?.txid, + ) + walletRepo.syncNodeAndWallet() + + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.lightning__close_success_title), + description = context.getString(R.string.lightning__close_success_msg), + ) + + _uiState.update { + it.copy( + isLoading = false, + closeSuccess = true, + ) + } + }, + onFailure = { error -> + Logger.error("Failed to close channel", error, context = TAG) + + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.lightning__close_error), + description = context.getString(R.string.lightning__close_error_msg), + ) + + _uiState.update { it.copy(isLoading = false) } + } + ) + } + } +} + +data class CloseConnectionUiState( + val isLoading: Boolean = false, + val closeSuccess: Boolean = false, +) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 9e0230c6e..f5fde754c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -28,18 +28,14 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending import to.bitkit.models.Toast -import to.bitkit.models.TransferType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo -import to.bitkit.repositories.TransferRepo -import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class LightningConnectionsViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -47,17 +43,12 @@ class LightningConnectionsViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, - private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, - private val transferRepo: TransferRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(LightningConnectionsUiState()) val uiState = _uiState.asStateFlow() - private val _closeConnectionUiState = MutableStateFlow(CloseConnectionUiState()) - val closeConnectionUiState = _closeConnectionUiState.asStateFlow() - init { observeState() observeLdkEvents() @@ -177,59 +168,6 @@ class LightningConnectionsViewModel @Inject constructor( } } - fun clearCloseConnectionState() { - _closeConnectionUiState.update { CloseConnectionUiState() } - } - - fun closeChannel(channelId: String) { - val channel = lightningRepo.lightningState.value.channels - .find { it.channelId == channelId } - ?: run { - Logger.error("No channel found for closing: $channelId", context = TAG) - return - } - - viewModelScope.launch { - _closeConnectionUiState.update { it.copy(isLoading = true) } - - lightningRepo.closeChannel(channel).fold( - onSuccess = { - transferRepo.createTransfer( - type = TransferType.COOP_CLOSE, - amountSats = channel.amountOnClose.toLong(), - channelId = channel.channelId, - fundingTxId = channel.fundingTxo?.txid, - ) - walletRepo.syncNodeAndWallet() - - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.lightning__close_success_title), - description = context.getString(R.string.lightning__close_success_msg), - ) - - _closeConnectionUiState.update { - it.copy( - isLoading = false, - closeSuccess = true, - ) - } - }, - onFailure = { error -> - Logger.error("Failed to close channel", error, context = TAG) - - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), - ) - - _closeConnectionUiState.update { it.copy(isLoading = false) } - } - ) - } - } - companion object { private const val TAG = "LightningConnectionsViewModel" } @@ -356,8 +294,3 @@ data class ChannelUi( val details: ChannelDetails, val closureReason: String? = null, ) - -data class CloseConnectionUiState( - val isLoading: Boolean = false, - val closeSuccess: Boolean = false, -) From 6e0c14f9ae536424a462e226ef5e75800d4e12eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Mar 2026 00:43:18 +0100 Subject: [PATCH 05/11] refactor: simplify --- .../settings/lightning/ChannelDetailScreen.kt | 6 +- .../lightning/ChannelDetailViewModel.kt | 184 +++++++++--------- .../lightning/CloseConnectionScreen.kt | 6 +- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 94 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index c3453ff12..e8bde468f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -86,7 +86,7 @@ import java.util.Locale fun ChannelDetailScreen( channelId: String, navController: NavController, - viewModel: ChannelDetailViewModel= hiltViewModel(), + viewModel: ChannelDetailViewModel = hiltViewModel(), ) { val context = LocalContext.current val app = appViewModel ?: return @@ -116,10 +116,6 @@ fun ChannelDetailScreen( is ChannelLoadState.NotFound -> { LaunchedEffect(Unit) { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.lightning__channel_not_found), - ) navController.popBackStack() } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index d7ae726d4..595401589 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -11,86 +11,85 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R -import to.bitkit.di.BgDispatcher import to.bitkit.ext.createChannelDetails +import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("TooManyFunctions") @HiltViewModel class ChannelDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, ) : ViewModel() { + companion object { + private const val TAG = "ChannelDetailViewModel" + } + private val _uiState = MutableStateFlow(ChannelDetailUiState()) val uiState = _uiState.asStateFlow() - fun loadChannel(channelId: String) { - viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(channelLoadState = ChannelLoadState.Loading) } + private val connectionText by lazy { context.getString(R.string.lightning__connection) } - val closedChannels = loadClosedChannels() - val channelUi = findChannelUi(channelId, closedChannels) + fun loadChannel(channelId: String) = viewModelScope.launch { + _uiState.update { it.copy(channelLoadState = ChannelLoadState.Loading) } - if (channelUi == null) { - _uiState.update { it.copy(channelLoadState = ChannelLoadState.NotFound) } - return@launch - } + val closedChannels = loadClosedChannels() + val channelUi = findChannelUi(channelId, closedChannels) - val isClosedChannel = closedChannels.any { it.details.channelId == channelId } + if (channelUi == null) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.lightning__channel_not_found), + ) + _uiState.update { it.copy(channelLoadState = ChannelLoadState.NotFound) } + return@launch + } - _uiState.update { - it.copy( - channelLoadState = ChannelLoadState.Success(channelUi), - paidOrders = blocktankRepo.blocktankState.value.paidOrders, - cjitEntries = blocktankRepo.blocktankState.value.cjitEntries, - isClosedChannel = isClosedChannel, - ) - } + val isClosedChannel = closedChannels.any { it.details.channelId == channelId } - fetchActivityTimestamp(channelId) - observeChannelUpdates(channelId, closedChannels) + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(channelUi), + paidOrders = blocktankRepo.blocktankState.value.paidOrders, + cjitEntries = blocktankRepo.blocktankState.value.cjitEntries, + isClosedChannel = isClosedChannel, + ) } + + fetchActivityTimestamp(channelId) + observeChannelUpdates(channelId, closedChannels) } - fun onPullToRefresh() { - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } - lightningRepo.sync() - blocktankRepo.refreshOrders() - delay(500) - _uiState.update { it.copy(isRefreshing = false) } - } + fun onPullToRefresh() = viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + lightningRepo.sync() + blocktankRepo.refreshOrders() + delay(500) + _uiState.update { it.copy(isRefreshing = false) } } private fun findChannelUi(channelId: String, closedChannels: List): ChannelUi? { val channels = lightningRepo.lightningState.value.channels val blocktankState = blocktankRepo.blocktankState.value - val connectionText = context.getString(R.string.lightning__connection) - return channels.find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - ?: getFailedOrdersAsChannels(blocktankState.paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + return resolveActiveChannel(channelId, channels, blocktankState.paidOrders) ?: closedChannels.find { it.details.channelId == channelId } ?: blocktankState.orders.find { it.id == channelId }?.let { order -> createChannelDetails().copy( @@ -104,12 +103,26 @@ class ChannelDetailViewModel @Inject constructor( } } + private fun resolveActiveChannel( + channelId: String, + channels: List, + paidOrders: List, + ): ChannelUi? = + channels.find { it.channelId == channelId } + ?.mapToUiModel(channels, paidOrders, connectionText) + ?: getPendingOrdersAsChannels(channels, paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, paidOrders, connectionText) + ?: getFailedOrdersAsChannels(paidOrders) + .find { it.channelId == channelId } + ?.mapToUiModel(channels, paidOrders, connectionText) + private suspend fun loadClosedChannels(): List { - val connectionText = context.getString(R.string.lightning__connection) val channels = lightningRepo.lightningState.value.channels val paidOrders = blocktankRepo.blocktankState.value.paidOrders return activityRepo.getClosedChannels(SortDirection.DESC) + .onFailure { Logger.error("Failed to load closed channels", it, context = TAG) } .getOrNull() ?.mapIndexed { index, closedChannel -> closedChannel.toChannelUi( @@ -121,62 +134,43 @@ class ChannelDetailViewModel @Inject constructor( .orEmpty() } - private fun fetchActivityTimestamp(channelId: String) { - viewModelScope.launch(bgDispatcher) { - val activities = activityRepo.getActivities( - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null, - ).getOrNull().orEmpty() - - val transferActivity = activities.firstOrNull { activity -> - activity is Activity.Onchain && - activity.v1.isTransfer && - activity.v1.channelId == channelId - } as? Activity.Onchain - - _uiState.update { - it.copy(txTime = transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp) - } + private fun fetchActivityTimestamp(channelId: String) = viewModelScope.launch { + val activities = activityRepo.getActivities( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + ).getOrNull().orEmpty() + + val transferActivity = activities.firstOrNull { activity -> + activity is Activity.Onchain && + activity.v1.isTransfer && + activity.v1.channelId == channelId + } as? Activity.Onchain + + _uiState.update { + it.copy(txTime = transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp) } } - private fun observeChannelUpdates(channelId: String, closedChannels: List) { - viewModelScope.launch(bgDispatcher) { - combine( - lightningRepo.lightningState, - blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - val channels = lightningState.channels - val connectionText = context.getString(R.string.lightning__connection) - - val updatedChannel = channels.find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - ?: getFailedOrdersAsChannels(blocktankState.paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - - val isClosedChannel = closedChannels.any { it.details.channelId == channelId } - - Triple(updatedChannel, blocktankState, isClosedChannel) - }.collect { (updatedChannel, blocktankState, isClosedChannel) -> - if (updatedChannel != null) { - _uiState.update { - it.copy( - channelLoadState = ChannelLoadState.Success(updatedChannel), - paidOrders = blocktankState.paidOrders, - cjitEntries = blocktankState.cjitEntries, - isClosedChannel = isClosedChannel, - ) - } + private fun observeChannelUpdates(channelId: String, closedChannels: List) = viewModelScope.launch { + combine( + lightningRepo.lightningState, + blocktankRepo.blocktankState, + ) { lightningState, blocktankState -> + val channels = lightningState.channels + + val updatedChannel = resolveActiveChannel(channelId, channels, blocktankState.paidOrders) + val isClosedChannel = closedChannels.any { it.details.channelId == channelId } + + Triple(updatedChannel, blocktankState, isClosedChannel) + }.collect { (updatedChannel, blocktankState, isClosedChannel) -> + if (updatedChannel != null) { + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(updatedChannel), + paidOrders = blocktankState.paidOrders, + cjitEntries = blocktankState.cjitEntries, + isClosedChannel = isClosedChannel, + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt index 039db65a0..58e620b47 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,9 +41,9 @@ import to.bitkit.ui.utils.withAccentBoldBright fun CloseConnectionScreen( channelId: String, navController: NavController, - viewModel: CloseConnectionViewModel= hiltViewModel(), + viewModel: CloseConnectionViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() // Handle success navigation LaunchedEffect(uiState.closeSuccess) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be7dc022d..5019b02e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,8 +89,8 @@ Spending base fee Block Height Channel ID - Channel not found Peer ID + Channel not found You can now pay anyone, anywhere, instantly. Spending Balance Ready Channel point From 20eb8f5749db459c2ad150dbdfe94653cc7f9cc0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Mar 2026 02:27:48 +0100 Subject: [PATCH 06/11] fix: wallet screen navigation transition Co-Authored-By: Claude Opus 4.6 --- app/src/main/java/to/bitkit/ui/ContentView.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 349e1469b..4d363da36 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -785,10 +785,7 @@ private fun NavGraphBuilder.home( ) } } - composable( - enterTransition = { Transitions.slideInHorizontally }, - exitTransition = { Transitions.slideOutHorizontally }, - ) { + composableWithDefaultTransitions { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() @@ -811,10 +808,7 @@ private fun NavGraphBuilder.home( forceCloseRemainingDuration = forceCloseRemainingDuration, ) } - composable( - enterTransition = { Transitions.slideInHorizontally }, - exitTransition = { Transitions.slideOutHorizontally }, - ) { + composableWithDefaultTransitions { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() From bc24cbc2b468c9a1657ed79b213639733726671b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Mar 2026 04:20:20 +0100 Subject: [PATCH 07/11] fix: transfer activity nav to channel detail --- app/src/main/java/to/bitkit/ui/ContentView.kt | 5 +---- .../settings/lightning/LightningConnectionsScreen.kt | 11 ----------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4d363da36..26b41298f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1186,10 +1186,7 @@ private fun NavGraphBuilder.activityItem( route = it.toRoute(), onExploreClick = { id -> navController.navigateToActivityExplore(id) }, onChannelClick = { channelId -> - navController.currentBackStackEntry?.savedStateHandle?.set("selectedChannelId", channelId) - navController.navigate(Routes.ConnectionsNav) { - launchSingleTop = true - } + navController.navigate(Routes.ChannelDetail(channelId)) }, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 68931c33c..1f5cfd43c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails @@ -66,7 +65,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors private const val CLOSED_CHANNEL_ALPHA = 0.64f -private const val CHANNEL_SELECTION_DELAY_MS = 200L object LightningConnectionsTestTags { const val SCREEN = "lightning_connections_screen" @@ -86,15 +84,6 @@ fun LightningConnectionsScreen( LaunchedEffect(Unit) { viewModel.refreshObservedState() - - val selectedChannelId = navController.previousBackStackEntry?.savedStateHandle?.get("selectedChannelId") - if (selectedChannelId != null) { - navController.previousBackStackEntry?.savedStateHandle?.remove("selectedChannelId") - delay(CHANNEL_SELECTION_DELAY_MS) - navController.navigate(Routes.ChannelDetail(selectedChannelId)) { - launchSingleTop = true - } - } } Content( From 2685e88ff7e3e1f30b58215149381e38fd00be8b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Mar 2026 04:46:40 +0100 Subject: [PATCH 08/11] fix: address pr review feedback Co-Authored-By: Claude Opus 4.6 --- .../settings/lightning/ChannelDetailScreen.kt | 29 +++++---- .../lightning/ChannelDetailViewModel.kt | 64 ++++++++++++------- .../lightning/CloseConnectionViewModel.kt | 4 +- .../LightningConnectionsViewModel.kt | 8 +-- 4 files changed, 65 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index e8bde468f..15267ec5c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -144,7 +145,7 @@ fun ChannelDetailScreen( val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) }, - onSupport = { order -> contactSupport(order, channel, context) }, + onSupport = { order -> contactSupport(order, channel, uiState.nodeId, context) }, onCloseConnection = { navController.navigate(Routes.CloseConnection(channelId = channel.details.channelId)) }, @@ -177,19 +178,19 @@ private fun Content( actions = { DrawerNavIcon() }, ) - // Check if the channel was opened via CJIT - val cjitEntry = cjitEntries.find { entry -> - entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid + val cjitEntry = remember(cjitEntries, channel) { + cjitEntries.find { entry -> + entry.channel?.fundingTx?.id == channel.details.fundingTxo?.txid + } } - // Check if the channel was opened via blocktank order - val blocktankOrder = blocktankOrders.find { order -> - // real channel - if (channel.details.fundingTxo?.txid != null) { - order.channel?.fundingTx?.id == channel.details.fundingTxo?.txid - } else { - // fake channel - order.id == channel.details.channelId + val blocktankOrder = remember(blocktankOrders, channel) { + blocktankOrders.find { order -> + if (channel.details.fundingTxo?.txid != null) { + order.channel?.fundingTx?.id == channel.details.fundingTxo?.txid + } else { + order.id == channel.details.channelId + } } } @@ -584,11 +585,13 @@ private fun formatUnixTimestamp(timestamp: Long): String { private fun contactSupport( order: Any, channel: ChannelUi, + nodeId: String, context: Context, ) { val intent = createSupportEmailIntent( order = order, channel = channel, + nodeId = nodeId, ) runCatching { context.startActivity(Intent.createChooser(intent, context.getString(R.string.lightning__support))) @@ -601,6 +604,7 @@ private fun contactSupport( private fun createSupportEmailIntent( order: Any, // IBtOrder or IcJitEntry channel: ChannelUi, + nodeId: String, ): Intent { val subject = "Bitkit Support [Channel]" @@ -624,6 +628,7 @@ private fun createSupportEmailIntent( appendLine() appendLine("Platform: ${Env.platform}") appendLine("Version: ${Env.version}") + appendLine("LDK node ID: $nodeId") }.trim() val uri = "mailto:${Env.SUPPORT_EMAIL}?subject=${Uri.encode(subject)}&body=${Uri.encode(body)}".toUri() diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index 595401589..54d99454c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -11,6 +11,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +29,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @Suppress("TooManyFunctions") @HiltViewModel @@ -45,6 +47,7 @@ class ChannelDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ChannelDetailUiState()) val uiState = _uiState.asStateFlow() + private var observerJob: Job? = null private val connectionText by lazy { context.getString(R.string.lightning__connection) } fun loadChannel(channelId: String) = viewModelScope.launch { @@ -70,18 +73,19 @@ class ChannelDetailViewModel @Inject constructor( paidOrders = blocktankRepo.blocktankState.value.paidOrders, cjitEntries = blocktankRepo.blocktankState.value.cjitEntries, isClosedChannel = isClosedChannel, + nodeId = lightningRepo.getNodeId().orEmpty(), ) } fetchActivityTimestamp(channelId) - observeChannelUpdates(channelId, closedChannels) + observeChannelUpdates(channelId) } fun onPullToRefresh() = viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } lightningRepo.sync() blocktankRepo.refreshOrders() - delay(500) + delay(500.milliseconds) _uiState.update { it.copy(isRefreshing = false) } } @@ -151,26 +155,41 @@ class ChannelDetailViewModel @Inject constructor( } } - private fun observeChannelUpdates(channelId: String, closedChannels: List) = viewModelScope.launch { - combine( - lightningRepo.lightningState, - blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - val channels = lightningState.channels - - val updatedChannel = resolveActiveChannel(channelId, channels, blocktankState.paidOrders) - val isClosedChannel = closedChannels.any { it.details.channelId == channelId } - - Triple(updatedChannel, blocktankState, isClosedChannel) - }.collect { (updatedChannel, blocktankState, isClosedChannel) -> - if (updatedChannel != null) { - _uiState.update { - it.copy( - channelLoadState = ChannelLoadState.Success(updatedChannel), - paidOrders = blocktankState.paidOrders, - cjitEntries = blocktankState.cjitEntries, - isClosedChannel = isClosedChannel, - ) + private fun observeChannelUpdates(channelId: String) { + observerJob?.cancel() + observerJob = viewModelScope.launch { + combine( + lightningRepo.lightningState, + blocktankRepo.blocktankState, + ) { lightningState, blocktankState -> + val channels = lightningState.channels + val updatedChannel = resolveActiveChannel(channelId, channels, blocktankState.paidOrders) + Pair(updatedChannel, blocktankState) + }.collect { (updatedChannel, blocktankState) -> + if (updatedChannel != null) { + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(updatedChannel), + paidOrders = blocktankState.paidOrders, + cjitEntries = blocktankState.cjitEntries, + nodeId = lightningRepo.getNodeId().orEmpty(), + ) + } + } else { + val freshClosed = loadClosedChannels() + val isNowClosed = freshClosed.any { it.details.channelId == channelId } + if (isNowClosed) { + val closedChannel = freshClosed.first { it.details.channelId == channelId } + _uiState.update { + it.copy( + channelLoadState = ChannelLoadState.Success(closedChannel), + isClosedChannel = true, + paidOrders = blocktankState.paidOrders, + cjitEntries = blocktankState.cjitEntries, + nodeId = lightningRepo.getNodeId().orEmpty(), + ) + } + } } } } @@ -184,6 +203,7 @@ data class ChannelDetailUiState( val txTime: ULong? = null, val isRefreshing: Boolean = false, val isClosedChannel: Boolean = false, + val nodeId: String = "", ) sealed interface ChannelLoadState { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt index 857edc129..59d76ad8b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionViewModel.kt @@ -69,8 +69,8 @@ class CloseConnectionViewModel @Inject constructor( ) } }, - onFailure = { error -> - Logger.error("Failed to close channel", error, context = TAG) + onFailure = { + Logger.error("Failed to close channel", it, context = TAG) ToastEventBus.send( type = Toast.ToastType.WARNING, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index f5fde754c..4ca1cc296 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -46,6 +46,10 @@ class LightningConnectionsViewModel @Inject constructor( private val activityRepo: ActivityRepo, ) : ViewModel() { + companion object { + private const val TAG = "LightningConnectionsViewModel" + } + private val _uiState = MutableStateFlow(LightningConnectionsUiState()) val uiState = _uiState.asStateFlow() @@ -167,10 +171,6 @@ class LightningConnectionsViewModel @Inject constructor( } } } - - companion object { - private const val TAG = "LightningConnectionsViewModel" - } } internal fun getPendingOrdersAsChannels( From 5b64e1ae42f4d75929803ff6fb4e2a466d24d544 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Mar 2026 05:32:46 +0100 Subject: [PATCH 09/11] fix: address pr review round 2 Co-Authored-By: Claude Opus 4.6 --- .../settings/lightning/ChannelDetailScreen.kt | 84 ++++++++++++------- .../lightning/ChannelDetailViewModel.kt | 5 +- .../lightning/CloseConnectionScreen.kt | 2 +- .../LightningConnectionsViewModel.kt | 8 +- 4 files changed, 62 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 15267ec5c..00638ec30 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -98,12 +98,48 @@ fun ChannelDetailScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onRefresh = { viewModel.onPullToRefresh() }, + onCopyText = { text -> + context.setClipboardText(text) + app.toast( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.common__copied), + description = text, + ) + }, + onOpenUrl = { txId -> + val url = getBlockExplorerUrl(txId) + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + }, + onSupport = { order, channel -> contactSupport(order, channel, uiState.nodeId, context) }, + onCloseConnection = { channelDetailId -> + navController.navigate(Routes.CloseConnection(channelId = channelDetailId)) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("CyclomaticComplexMethod") +@Composable +private fun Content( + uiState: ChannelDetailUiState = ChannelDetailUiState(), + onBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onCopyText: (String) -> Unit = {}, + onOpenUrl: (String) -> Unit = {}, + onSupport: (Any, ChannelUi) -> Unit = { _, _ -> }, + onCloseConnection: (String) -> Unit = {}, +) { when (val loadState = uiState.channelLoadState) { is ChannelLoadState.Loading -> { ScreenColumn { AppTopBar( titleText = "", - onBackClick = { navController.popBackStack() }, + onBackClick = onBack, actions = { DrawerNavIcon() }, ) Box( @@ -116,39 +152,23 @@ fun ChannelDetailScreen( } is ChannelLoadState.NotFound -> { - LaunchedEffect(Unit) { - navController.popBackStack() - } + LaunchedEffect(Unit) { onBack() } } is ChannelLoadState.Success -> { - val channel = loadState.channel - Content( - channel = channel, + ChannelDetailContent( + channel = loadState.channel, blocktankOrders = uiState.paidOrders, cjitEntries = uiState.cjitEntries, txTime = uiState.txTime, isRefreshing = uiState.isRefreshing, isClosedChannel = uiState.isClosedChannel, - onBack = { navController.popBackStack() }, - onRefresh = { viewModel.onPullToRefresh() }, - onCopyText = { text -> - context.setClipboardText(text) - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text, - ) - }, - onOpenUrl = { txId -> - val url = getBlockExplorerUrl(txId) - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - context.startActivity(intent) - }, - onSupport = { order -> contactSupport(order, channel, uiState.nodeId, context) }, - onCloseConnection = { - navController.navigate(Routes.CloseConnection(channelId = channel.details.channelId)) - }, + onBack = onBack, + onRefresh = onRefresh, + onCopyText = onCopyText, + onOpenUrl = onOpenUrl, + onSupport = { onSupport(it, loadState.channel) }, + onCloseConnection = { onCloseConnection(loadState.channel.details.channelId) }, ) } } @@ -157,7 +177,7 @@ fun ChannelDetailScreen( @OptIn(ExperimentalMaterial3Api::class) @Suppress("CyclomaticComplexMethod") @Composable -private fun Content( +private fun ChannelDetailContent( channel: ChannelUi, blocktankOrders: List = emptyList(), cjitEntries: List = emptyList(), @@ -640,7 +660,7 @@ private fun createSupportEmailIntent( @Composable private fun PreviewOpenChannel() { AppThemeSurface { - Content( + ChannelDetailContent( channel = ChannelUi( name = "Connection 1", details = createChannelDetails().copy( @@ -661,7 +681,7 @@ private fun PreviewOpenChannel() { @Composable private fun PreviewChannelWithOrder() { AppThemeSurface { - Content( + ChannelDetailContent( channel = ChannelUi( name = "Connection 2", details = createChannelDetails().copy( @@ -747,7 +767,7 @@ private fun PreviewChannelWithOrder() { @Composable private fun PreviewPendingOrder() { AppThemeSurface { - Content( + ChannelDetailContent( channel = ChannelUi( name = "Connection 3 (Pending)", details = createChannelDetails().copy( @@ -834,7 +854,7 @@ private fun PreviewPendingOrder() { @Composable private fun PreviewExpiredOrder() { AppThemeSurface { - Content( + ChannelDetailContent( channel = ChannelUi( name = "Connection 4 (Failed)", details = createChannelDetails().copy( @@ -904,7 +924,7 @@ private fun PreviewExpiredOrder() { @Composable private fun PreviewChannelWithCjit() { AppThemeSurface { - Content( + ChannelDetailContent( channel = ChannelUi( name = "CJIT Connection", details = createChannelDetails().copy( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index 54d99454c..4d6dbb7e6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -23,6 +23,7 @@ import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.ext.createChannelDetails import to.bitkit.models.Toast +import to.bitkit.models.safe import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo @@ -100,7 +101,7 @@ class ChannelDetailViewModel @Inject constructor( channelId = order.id, counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), outboundCapacityMsat = order.clientBalanceSat * 1000u, inboundCapacityMsat = order.lspBalanceSat * 1000u, ).mapToUiModel(channels, blocktankState.paidOrders, connectionText) @@ -189,6 +190,8 @@ class ChannelDetailViewModel @Inject constructor( nodeId = lightningRepo.getNodeId().orEmpty(), ) } + } else { + _uiState.update { it.copy(channelLoadState = ChannelLoadState.NotFound) } } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt index 58e620b47..60755f960 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/CloseConnectionScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +22,7 @@ import androidx.compose.ui.res.stringResource 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 to.bitkit.R import to.bitkit.ui.Routes diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 4ca1cc296..9969f4302 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -28,6 +28,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending import to.bitkit.models.Toast +import to.bitkit.models.safe import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo @@ -35,6 +36,7 @@ import to.bitkit.repositories.LogsRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class LightningConnectionsViewModel @Inject constructor( @@ -131,7 +133,7 @@ class LightningConnectionsViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } refreshObservedState() - delay(500) + delay(500.milliseconds) _uiState.update { it.copy(isRefreshing = false) } } } @@ -188,7 +190,7 @@ internal fun getPendingOrdersAsChannels( channelId = order.id, counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), outboundCapacityMsat = order.clientBalanceSat * 1000u, inboundCapacityMsat = order.lspBalanceSat * 1000u, ) @@ -205,7 +207,7 @@ internal fun getFailedOrdersAsChannels( channelId = order.id, counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), outboundCapacityMsat = order.clientBalanceSat * 1000u, inboundCapacityMsat = order.lspBalanceSat * 1000u, isChannelReady = false, From ddd78bb72820416383bc742670c103b9697ba3f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Mar 2026 11:14:38 +0100 Subject: [PATCH 10/11] fix: guard pending orders from not-found Co-Authored-By: Claude Opus 4.6 --- .../to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index 4d6dbb7e6..82ec618b4 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -179,6 +179,7 @@ class ChannelDetailViewModel @Inject constructor( } else { val freshClosed = loadClosedChannels() val isNowClosed = freshClosed.any { it.details.channelId == channelId } + val isPendingOrder = blocktankState.orders.any { it.id == channelId } if (isNowClosed) { val closedChannel = freshClosed.first { it.details.channelId == channelId } _uiState.update { @@ -190,7 +191,7 @@ class ChannelDetailViewModel @Inject constructor( nodeId = lightningRepo.getNodeId().orEmpty(), ) } - } else { + } else if (!isPendingOrder) { _uiState.update { it.copy(channelLoadState = ChannelLoadState.NotFound) } } } From 659e634e1c8caee58a500b3281dd1ff4a40a3e0a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Mar 2026 12:12:27 +0100 Subject: [PATCH 11/11] fix: address claude.md rule violations Co-Authored-By: Claude Opus 4.6 --- .../settings/lightning/ChannelDetailScreen.kt | 18 +-------- .../LightningConnectionsViewModel.kt | 40 ++++++++++--------- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 00638ec30..9e4168fb9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -234,7 +234,6 @@ private fun ChannelDetailContent( .navigationBarsPadding() .testTag("ChannelScrollView") ) { - // Channel Display Section VerticalSpacer(16.dp) LightningChannel( capacity = capacity, @@ -245,7 +244,6 @@ private fun ChannelDetailContent( VerticalSpacer(32.dp) HorizontalDivider() - // Status Section SectionTitle(stringResource(R.string.lightning__status)) ChannelStatusView( channel = channel, @@ -255,7 +253,6 @@ private fun ChannelDetailContent( VerticalSpacer(16.dp) HorizontalDivider() - // Order Details Section if (order != null) { SectionTitle(stringResource(R.string.lightning__order_details)) @@ -290,7 +287,6 @@ private fun ChannelDetailContent( } ) - // Order expiry for pending blocktank orders only if (blocktankOrder != null && (blocktankOrder.state2 == BtOrderState2.CREATED || blocktankOrder.state2 == BtOrderState2.PAID) ) { @@ -302,7 +298,6 @@ private fun ChannelDetailContent( ) } - // Transaction details if available val fundingTxId = when (order) { is IBtOrder -> order.channel?.fundingTx?.id is IcJitEntry -> order.channel?.fundingTx?.id @@ -326,7 +321,6 @@ private fun ChannelDetailContent( ) } - // Order fee val orderFee = when (order) { is IBtOrder -> order.feeSat - order.clientBalanceSat is IcJitEntry -> order.feeSat @@ -342,7 +336,6 @@ private fun ChannelDetailContent( } } - // Balance Section SectionTitle(stringResource(R.string.lightning__balance)) SectionRow( @@ -374,7 +367,6 @@ private fun ChannelDetailContent( modifier = Modifier.testTag("TotalSize") ) - // Fees Section SectionTitle(stringResource(R.string.lightning__fees)) SectionRow( @@ -394,7 +386,6 @@ private fun ChannelDetailContent( } ) - // Other Section SectionTitle(stringResource(R.string.lightning__other)) SectionRow( @@ -420,7 +411,6 @@ private fun ChannelDetailContent( ) } - // Closed date for closed channels val orderClosedAt = when (order) { is IBtOrder -> order.channel?.close?.registeredAt is IcJitEntry -> order.channel?.close?.registeredAt @@ -435,7 +425,6 @@ private fun ChannelDetailContent( ) } - // Channel ID SectionRow( name = stringResource(R.string.lightning__channel_id), valueContent = { @@ -449,7 +438,6 @@ private fun ChannelDetailContent( onClick = { onCopyText(channel.details.channelId) } ) - // Channel point (funding transaction + output index) channel.details.fundingTxo?.let { fundingTxo -> val channelPoint = "${fundingTxo.txid}:${fundingTxo.vout}" SectionRow( @@ -466,7 +454,6 @@ private fun ChannelDetailContent( ) } - // Peer ID SectionRow( name = stringResource(R.string.lightning__channel_node_id), valueContent = { @@ -480,7 +467,6 @@ private fun ChannelDetailContent( onClick = { onCopyText(channel.details.counterpartyNodeId) } ) - // Closure reason for closed channels channel.closureReason?.let { closureReason -> SectionRow( name = stringResource(R.string.lightning__closure_reason), @@ -490,7 +476,6 @@ private fun ChannelDetailContent( ) } - // Action Buttons FillHeight() VerticalSpacer(32.dp) Row( @@ -616,13 +601,12 @@ private fun contactSupport( runCatching { context.startActivity(Intent.createChooser(intent, context.getString(R.string.lightning__support))) }.onFailure { - // Fallback to opening support website context.startActivity(Intent(Intent.ACTION_VIEW, Env.SYNONYM_CONTACT.toUri())) } } private fun createSupportEmailIntent( - order: Any, // IBtOrder or IcJitEntry + order: Any, channel: ChannelUi, nodeId: String, ): Intent { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 9969f4302..d4d3f1e88 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -81,8 +81,8 @@ class LightningConnectionsViewModel @Inject constructor( ) } } - .onFailure { e -> - Logger.error("Failed to load closed channels", e, context = TAG) + .onFailure { + Logger.error("Failed to load closed channels", it, context = TAG) } } } @@ -93,24 +93,25 @@ class LightningConnectionsViewModel @Inject constructor( lightningRepo.lightningState, blocktankRepo.blocktankState, ) { lightningState, blocktankState -> + Pair(lightningState, blocktankState) + }.collect { (lightningState, blocktankState) -> val channels = lightningState.channels - val isNodeRunning = lightningState.nodeLifecycleState.isRunning() val connectionText = context.getString(R.string.lightning__connection) - _uiState.value.copy( - isNodeRunning = isNodeRunning, - openChannels = channels.filterOpen().map { channel -> - channel.mapToUiModel(channels, blocktankState.paidOrders, connectionText) - }, - pendingConnections = getPendingConnections(channels, blocktankState.paidOrders) - .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, - failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) - .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, - localBalance = calculateLocalBalance(channels), - remoteBalance = channels.calculateRemoteBalance(), - ) - }.collect { newState -> - _uiState.update { newState } + _uiState.update { + it.copy( + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + openChannels = channels.filterOpen().map { channel -> + channel.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + }, + pendingConnections = getPendingConnections(channels, blocktankState.paidOrders) + .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, + failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) + .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) }, + localBalance = calculateLocalBalance(channels), + remoteBalance = channels.calculateRemoteBalance(), + ) + } } } } @@ -119,7 +120,10 @@ class LightningConnectionsViewModel @Inject constructor( viewModelScope.launch { lightningRepo.nodeEvents.collect { event -> if (event is Event.ChannelPending || event is Event.ChannelReady || event is Event.ChannelClosed) { - Logger.debug("Channel event received: ${event::class.simpleName}, triggering refresh") + Logger.debug( + "Received channel event '${event::class.simpleName}', triggering refresh", + context = TAG, + ) refreshObservedState() if (event is Event.ChannelClosed) { loadClosedChannels()