From 243e803b08688a52bbd95e5b31add45b9674968f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 18:14:12 -0300 Subject: [PATCH 1/8] fix: add retry to coin selection loading Co-Authored-By: Claude Opus 5 (1M context) --- .../wallets/send/SendCoinSelectionScreen.kt | 64 +++++++- .../send/SendCoinSelectionViewModel.kt | 82 +++++++--- app/src/main/res/values/strings.xml | 1 + .../send/SendCoinSelectionViewModelTest.kt | 145 +++++++++++++++++- changelog.d/next/616.fixed.md | 1 + 5 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 changelog.d/next/616.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index 0ec002a476..d85b5b39f3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -38,12 +39,14 @@ import to.bitkit.ext.uniqueUtxoKey import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.activityListViewModel +import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Subtitle import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.VerticalSpacer @@ -55,6 +58,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.utils.AppError @Composable fun SendCoinSelectionScreen( @@ -70,11 +74,14 @@ fun SendCoinSelectionScreen( val activity = activityListViewModel ?: return val onchainActivities by activity.onchainActivities.collectAsStateWithLifecycle() - LaunchedEffect(requiredAmount, onchainActivities) { - viewModel.setOnchainActivities(onchainActivities.orEmpty()) + LaunchedEffect(requiredAmount, address) { viewModel.loadUtxos(requiredAmount, address) } + LaunchedEffect(onchainActivities) { + viewModel.setOnchainActivities(onchainActivities.orEmpty()) + } + Content( uiState = uiState, tagsByTxId = tagsByTxId, @@ -82,6 +89,7 @@ fun SendCoinSelectionScreen( onContinue = { onContinue(uiState.selectedUtxos) }, onClickUtxo = { viewModel.onToggleUtxo(it) }, onRenderUtxo = { viewModel.loadTagsForUtxo(it) }, + onRetry = { viewModel.loadUtxos(requiredAmount, address) }, ) } @@ -94,6 +102,7 @@ private fun Content( onContinue: () -> Unit = {}, onClickUtxo: (SpendableUtxo) -> Unit = {}, onRenderUtxo: (String) -> Unit = {}, + onRetry: () -> Unit = {}, ) { Column( modifier = modifier @@ -110,6 +119,15 @@ private fun Content( .weight(1f) .padding(horizontal = 16.dp) ) { + if (uiState.loadError != null && uiState.availableUtxos.isEmpty()) { + item { + LoadErrorState( + isLoading = uiState.isLoading, + onRetry = onRetry, + ) + } + } + // Utxo items items(uiState.availableUtxos) { utxo -> UtxoRow( @@ -161,6 +179,33 @@ private fun Content( } } +@Composable +private fun LoadErrorState( + isLoading: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .testTag("CoinSelectionLoadError") + ) { + BodyM( + text = stringResource(R.string.wallet__selection_load_error), + color = Colors.White64, + textAlign = TextAlign.Center, + ) + VerticalSpacer(16.dp) + SecondaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetry, + isLoading = isLoading, + modifier = Modifier.testTag("CoinSelectionRetry") + ) + } +} + @Composable private fun UtxoRow( utxo: SpendableUtxo, @@ -269,3 +314,18 @@ private fun PreviewEmpty() { } } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewLoadError() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = CoinSelectionUiState( + loadError = AppError("Node is not setup"), + ), + modifier = Modifier.sheetHeight(), + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 1f13350f3c..2d52875d1b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -13,6 +13,8 @@ import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -21,11 +23,15 @@ import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.repositories.NodeNotRunningError +import to.bitkit.repositories.NodeRunTimeoutError import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class SendCoinSelectionViewModel @Inject constructor( @@ -35,6 +41,12 @@ class SendCoinSelectionViewModel @Inject constructor( ) : ViewModel() { companion object { private const val TAG = "SendCoinSelectionViewModel" + + /** Max attempts to list spendable outputs while the node is in a transient state. */ + private const val MAX_LIST_UTXOS_ATTEMPTS = 3 + + /** Base delay between list spendable outputs attempts, multiplied by the attempt number. */ + private val LIST_UTXOS_RETRY_DELAY = 1.seconds } private val _uiState = MutableStateFlow(CoinSelectionUiState()) @@ -49,34 +61,58 @@ class SendCoinSelectionViewModel @Inject constructor( this.onchainActivities = onchainActivities } - fun loadUtxos(requiredAmount: ULong, address: String) = viewModelScope.launch { - runCatching { - val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() - .sortedByDescending { it.valueSats } - - val totalRequired = calculateTotalRequired( - address = address, - amountSats = requiredAmount, - utxosToSpend = sortedUtxos, - ) + private var loadJob: Job? = null - val totalSelected = sortedUtxos.sumOf { it.valueSats } + fun loadUtxos(requiredAmount: ULong, address: String) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runSuspendCatching { + val sortedUtxos = listSpendableOutputsWithRetry().getOrThrow() + .sortedByDescending { it.valueSats } - _uiState.update { state -> - state.copy( - availableUtxos = sortedUtxos.toImmutableList(), - selectedUtxos = sortedUtxos.toImmutableList(), - totalRequiredSat = totalRequired, - totalSelectedSat = totalSelected, - isSelectionValid = validateCoinSelection(totalSelected, totalRequired), + val totalRequired = calculateTotalRequired( + address = address, + amountSats = requiredAmount, + utxosToSpend = sortedUtxos, ) + + val totalSelected = sortedUtxos.sumOf { it.valueSats } + + _uiState.update { state -> + state.copy( + availableUtxos = sortedUtxos.toImmutableList(), + selectedUtxos = sortedUtxos.toImmutableList(), + totalRequiredSat = totalRequired, + totalSelectedSat = totalSelected, + isSelectionValid = validateCoinSelection(totalSelected, totalRequired), + isLoading = false, + loadError = null, + ) + } + }.onFailure { error -> + Logger.error("Failed to load UTXOs for coin selection", error, context = TAG) + _uiState.update { it.copy(isLoading = false, loadError = error) } } - }.onFailure { - Logger.error("Failed to load UTXOs for coin selection", it, context = TAG) - ToastEventBus.send(Exception("Failed to load UTXOs: ${it.message}")) } } + private suspend fun listSpendableOutputsWithRetry(): Result> { + var result = lightningRepo.listSpendableOutputs() + for (attempt in 1 until MAX_LIST_UTXOS_ATTEMPTS) { + if (result.exceptionOrNull()?.isTransientNodeError() != true) return result + Logger.debug("Retrying 'listSpendableOutputs' after attempt '$attempt'", context = TAG) + delay(LIST_UTXOS_RETRY_DELAY * attempt) + result = lightningRepo.listSpendableOutputs() + } + return result + } + + private fun Throwable.isTransientNodeError(): Boolean = when (this) { + is NodeNotRunningError, is NodeRunTimeoutError, is ServiceError.NodeNotSetup -> true + else -> false + } + fun loadTagsForUtxo(txId: String) { if (_tagsByTxId.value.containsKey(txId)) return @@ -148,4 +184,6 @@ data class CoinSelectionUiState( val totalRequiredSat: ULong = 0u, val totalSelectedSat: ULong = 0u, val isSelectionValid: Boolean = false, + val isLoading: Boolean = false, + val loadError: Throwable? = null, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad96bf23fc..6ed4e82f84 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1375,6 +1375,7 @@ Scan QR <accent>Send\nbitcoin</accent>\nto your\nsavings balance Savings + Bitkit could not load your coins. Please try again. Coin Selection Total required Total selected diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt index 6721b9bd67..1a4065087a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.send import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -11,13 +12,23 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.NodeNotRunningError +import to.bitkit.repositories.NodeRunTimeoutError import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -117,15 +128,145 @@ class SendCoinSelectionViewModelTest : BaseUnitTest() { assertFalse(state.isSelectionValid) } + @Test + fun `loadUtxos retries when node is not setup and loads utxos on success`() = test { + whenever(lightningRepo.listSpendableOutputs()).thenReturn( + Result.failure(ServiceError.NodeNotSetup()), + Result.success(listOf(SMALL_UTXO, LARGE_UTXO)), + ) + stubFee() + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + val state = sut.uiState.value + verify(lightningRepo, times(2)).listSpendableOutputs() + assertEquals(listOf(LARGE_UTXO, SMALL_UTXO), state.availableUtxos) + assertEquals(listOf(LARGE_UTXO, SMALL_UTXO), state.selectedUtxos) + assertNull(state.loadError) + assertFalse(state.isLoading) + assertTrue(state.isSelectionValid) + } + + @Test + fun `loadUtxos retries node run timeout and not running errors`() = test { + whenever(lightningRepo.listSpendableOutputs()).thenReturn( + Result.failure(NodeRunTimeoutError("listSpendableOutputs")), + Result.failure(NodeNotRunningError("listSpendableOutputs", NodeLifecycleState.Stopped)), + Result.success(listOf(LARGE_UTXO)), + ) + stubFee() + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + verify(lightningRepo, times(3)).listSpendableOutputs() + assertEquals(listOf(LARGE_UTXO), sut.uiState.value.availableUtxos) + assertNull(sut.uiState.value.loadError) + } + + @Test + fun `loadUtxos sets load error after bounded attempts without toast`() = test { + val error = ServiceError.NodeNotSetup() + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(error)) + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + val state = sut.uiState.value + verify(lightningRepo, times(3)).listSpendableOutputs() + assertEquals(error, state.loadError) + assertFalse(state.isLoading) + assertTrue(state.availableUtxos.isEmpty()) + assertFalse(state.isSelectionValid) + assertTrue(toasts.isEmpty()) + collectJob.cancel() + } + + @Test + fun `loadUtxos does not retry non transient list failure`() = test { + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(AppError("wallet failure"))) + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + verify(lightningRepo, times(1)).listSpendableOutputs() + assertIs(sut.uiState.value.loadError) + } + + @Test + fun `loadUtxos does not retry fee calculation failure`() = test { + val error = ServiceError.NodeNotSetup() + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(listOf(LARGE_UTXO))) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.failure(error)) + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + verify(lightningRepo, times(1)).listSpendableOutputs() + verify(lightningRepo, times(1)).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + assertEquals(error, sut.uiState.value.loadError) + assertFalse(sut.uiState.value.isLoading) + } + + @Test + fun `loadUtxos clears load error on successful retry`() = test { + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(AppError("wallet failure"))) + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + loadUtxos(utxos = listOf(LARGE_UTXO)) + + val state = sut.uiState.value + assertNull(state.loadError) + assertEquals(listOf(LARGE_UTXO), state.availableUtxos) + } + + @Test + fun `loadUtxos keeps load error visible while retry is in progress`() = test { + val error = AppError("wallet failure") + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(error)) + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(ServiceError.NodeNotSetup())) + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + + val state = sut.uiState.value + assertTrue(state.isLoading) + assertEquals(error, state.loadError) + advanceUntilIdle() + assertFalse(sut.uiState.value.isLoading) + } + + @Test + fun `setOnchainActivities does not reset manual selection`() = test { + loadUtxos(utxos = listOf(LARGE_UTXO, SMALL_UTXO)) + sut.onToggleUtxo(SMALL_UTXO) + + sut.setOnchainActivities(emptyList()) + advanceUntilIdle() + + assertEquals(listOf(LARGE_UTXO), sut.uiState.value.selectedUtxos) + verify(lightningRepo, times(1)).listSpendableOutputs() + } + private suspend fun TestScope.loadUtxos( utxos: List, requiredAmount: ULong = REQUIRED_AMOUNT, fee: ULong = FEE, ) { whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(utxos)) - whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(fee)) + stubFee(fee) sut.loadUtxos(requiredAmount, ADDRESS) advanceUntilIdle() } + + private suspend fun stubFee(fee: ULong = FEE) { + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(fee)) + } } diff --git a/changelog.d/next/616.fixed.md b/changelog.d/next/616.fixed.md new file mode 100644 index 0000000000..938b8433b7 --- /dev/null +++ b/changelog.d/next/616.fixed.md @@ -0,0 +1 @@ +Coin selection now retries while the Lightning node is starting and shows an inline error with a retry button when your coins cannot be loaded. From 8e516f904484b0036f87017f4e583bd3cf2c3f36 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 18:18:02 -0300 Subject: [PATCH 2/8] fix: stop retrying node run timeout in coin selection Co-Authored-By: Claude Opus 5 (1M context) --- .../wallets/send/SendCoinSelectionScreen.kt | 16 ++++++++++++++++ .../wallets/send/SendCoinSelectionViewModel.kt | 3 +-- .../send/SendCoinSelectionViewModelTest.kt | 18 +++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index d85b5b39f3..a73379f305 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -45,6 +47,7 @@ import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Subtitle @@ -119,6 +122,19 @@ private fun Content( .weight(1f) .padding(horizontal = 16.dp) ) { + if (uiState.isLoading && uiState.loadError == null && uiState.availableUtxos.isEmpty()) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .testTag("CoinSelectionLoading") + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + } + if (uiState.loadError != null && uiState.availableUtxos.isEmpty()) { item { LoadErrorState( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 2d52875d1b..7edf25b70f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -27,7 +27,6 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.NodeNotRunningError -import to.bitkit.repositories.NodeRunTimeoutError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject @@ -109,7 +108,7 @@ class SendCoinSelectionViewModel @Inject constructor( } private fun Throwable.isTransientNodeError(): Boolean = when (this) { - is NodeNotRunningError, is NodeRunTimeoutError, is ServiceError.NodeNotSetup -> true + is NodeNotRunningError, is ServiceError.NodeNotSetup -> true else -> false } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt index 1a4065087a..41ada33cbb 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModelTest.kt @@ -149,9 +149,8 @@ class SendCoinSelectionViewModelTest : BaseUnitTest() { } @Test - fun `loadUtxos retries node run timeout and not running errors`() = test { + fun `loadUtxos retries node not running error`() = test { whenever(lightningRepo.listSpendableOutputs()).thenReturn( - Result.failure(NodeRunTimeoutError("listSpendableOutputs")), Result.failure(NodeNotRunningError("listSpendableOutputs", NodeLifecycleState.Stopped)), Result.success(listOf(LARGE_UTXO)), ) @@ -160,11 +159,24 @@ class SendCoinSelectionViewModelTest : BaseUnitTest() { sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) advanceUntilIdle() - verify(lightningRepo, times(3)).listSpendableOutputs() + verify(lightningRepo, times(2)).listSpendableOutputs() assertEquals(listOf(LARGE_UTXO), sut.uiState.value.availableUtxos) assertNull(sut.uiState.value.loadError) } + @Test + fun `loadUtxos does not retry node run timeout error`() = test { + val error = NodeRunTimeoutError("listSpendableOutputs") + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.failure(error)) + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + verify(lightningRepo, times(1)).listSpendableOutputs() + assertEquals(error, sut.uiState.value.loadError) + assertFalse(sut.uiState.value.isLoading) + } + @Test fun `loadUtxos sets load error after bounded attempts without toast`() = test { val error = ServiceError.NodeNotSetup() From 85757ed55b4e471d5776e47fb06d78f30c0194bc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:24:51 -0300 Subject: [PATCH 3/8] docs: add manual coin selection load journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 + .../manual-coin-selection-load.xml | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 journeys/coin-selection/manual-coin-selection-load.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..a435a610e2 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -116,6 +116,7 @@ fixtures, push notifications) live in each suite's README. | --- | --- | --- | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | +| [coin-selection](coin-selection) | 1 | Manual coin selection load on the Send sheet; no README | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | | [notification-permission](notification-permission) | 4 | Background-setup toggles | @@ -142,6 +143,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/coin-selection/manual-coin-selection-load.xml b/journeys/coin-selection/manual-coin-selection-load.xml new file mode 100644 index 0000000000..f8100d0cea --- /dev/null +++ b/journeys/coin-selection/manual-coin-selection-load.xml @@ -0,0 +1,40 @@ + + + Verifies the Send coin selection screen loads the wallet's UTXOs without a toast, and that a + manual selection survives an activity update while the screen is open (the load runs once per + amount and address; activity changes only refresh tag lookup). + + The inline load error state (text "Bitkit could not load your coins. Please try again.", testTag + "CoinSelectionLoadError", with a Retry button, testTag "CoinSelectionRetry") is not reachable on + a healthy wallet: the Send amount screen shows "Connecting to network" (testTag + "sync_node_view") until the node runs, and fee calculation falls back to a fixed fee instead of + failing. It was checked with a temporary local change that failed 'listSpendableOutputs'. + SendCoinSelectionViewModelTest.kt covers it. Both tags sit on a SecondaryButton wrapper and on a + Column that carry no semantics of their own, so they do not reach `android layout`; assert the + error from its text or a screenshot. + + Precondition: onboarded dev wallet with at least two confirmed Savings UTXOs, funded through the + lsp regtest deposit and mine (see ../README.md). Note a Savings address from Receive before + starting, and a valid regtest address to send to. Start on the wallet home screen. This journey + changes the Coin Selection setting to Manual and adds a 10 000 sat deposit; the last actions + restore the previous setting. + + + Tap the header menu (testTag "HeaderMenu") and then Settings (testTag "DrawerSettings") + Tap the Advanced tab (testTag "Tab-advanced") and then Coin Selection (testTag "CoinSelectPreference") + Note which method and autopilot mode are checked, then tap Manual (testTag "manual_button") + Go back to the wallet home screen + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.0001" to.bitkit.dev + Verify the amount field (testTag "SendNumberField") shows 10 000, waiting through "Connecting to network" (testTag "sync_node_view") if it is shown + Tap Continue (testTag "ContinueAmount") + Verify the coin selection screen (testTag "coin_selection_screen") lists one row per UTXO (testTags starting with "utxo_row_"), all toggled on, with TOTAL REQUIRED and TOTAL SELECTED above zero + Verify no error toast appeared and the load error text "Bitkit could not load your coins. Please try again." is not shown + Tap the smallest UTXO row once to deselect it + Verify TOTAL SELECTED decreased by that UTXO's amount + Run ./lsp POST /regtest/chain/deposit '{"address":"<savings address>","amountSat":10000}' and then ./lsp POST /regtest/chain/mine '{"count":1}' + Wait about 60 seconds without touching the screen, for the wallet to sync the new activity + Verify the coin selection screen still shows the same UTXO rows, the deselected row is still off, and TOTAL SELECTED is unchanged + Close the Send sheet without continuing + Open Settings, Advanced, Coin Selection again and restore the method and autopilot mode noted earlier + + From a0fd136690de6eed961bc9f15a26e5b25e8b2c84 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:40:04 -0300 Subject: [PATCH 4/8] fix: center coin selection load states in list area Co-Authored-By: Claude Opus 5 (1M context) --- .../wallets/send/SendCoinSelectionScreen.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index a73379f305..d44dcdc656 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -127,7 +127,7 @@ private fun Content( Box( contentAlignment = Alignment.Center, modifier = Modifier - .fillMaxWidth() + .fillParentMaxSize() .testTag("CoinSelectionLoading") ) { GradientCircularProgressIndicator(modifier = Modifier.size(32.dp)) @@ -140,6 +140,7 @@ private fun Content( LoadErrorState( isLoading = uiState.isLoading, onRetry = onRetry, + modifier = Modifier.fillParentMaxSize() ) } } @@ -203,6 +204,7 @@ private fun LoadErrorState( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = modifier .fillMaxWidth() .testTag("CoinSelectionLoadError") @@ -331,6 +333,21 @@ private fun PreviewEmpty() { } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewLoading() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = CoinSelectionUiState( + isLoading = true, + ), + modifier = Modifier.sheetHeight(), + ) + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewLoadError() { From f7b31b3b0f9b66c1db27c7ae9a1ed3f23ad0e78a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:04:06 -0300 Subject: [PATCH 5/8] docs: close received sheet in coin selection journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/coin-selection/manual-coin-selection-load.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/journeys/coin-selection/manual-coin-selection-load.xml b/journeys/coin-selection/manual-coin-selection-load.xml index f8100d0cea..5e6aa4c770 100644 --- a/journeys/coin-selection/manual-coin-selection-load.xml +++ b/journeys/coin-selection/manual-coin-selection-load.xml @@ -32,7 +32,8 @@ Tap the smallest UTXO row once to deselect it Verify TOTAL SELECTED decreased by that UTXO's amount Run ./lsp POST /regtest/chain/deposit '{"address":"<savings address>","amountSat":10000}' and then ./lsp POST /regtest/chain/mine '{"count":1}' - Wait about 60 seconds without touching the screen, for the wallet to sync the new activity + Wait about 60 seconds for the wallet to sync the new activity, without touching the screen apart from the next step + The deposit is an ordinary incoming payment, so the Received sheet (testTag "new_transaction_sheet") opens over coin selection; close it with its OK button (testTag "ReceivedTransactionButton"). Do not press Back, which would close the Send sheet and the selection under it Verify the coin selection screen still shows the same UTXO rows, the deselected row is still off, and TOTAL SELECTED is unchanged Close the Send sheet without continuing Open Settings, Advanced, Coin Selection again and restore the method and autopilot mode noted earlier From 9fe42838dfae26fff1cd5729b10de03fd00d79c3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:04:15 -0300 Subject: [PATCH 6/8] docs: drop stale utxo reload note from journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/coin-selection/manual-coin-selection.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/journeys/coin-selection/manual-coin-selection.xml b/journeys/coin-selection/manual-coin-selection.xml index c0fb25b717..ce4de28ac3 100644 --- a/journeys/coin-selection/manual-coin-selection.xml +++ b/journeys/coin-selection/manual-coin-selection.xml @@ -8,10 +8,6 @@ deposits to a Savings address and mine a block. Start on the wallet home screen. The send uses your own Savings address (Settings > Advanced > Address Viewer); nothing is broadcast. - The screen reloads UTXOs and reselects all of them whenever the on-chain activity list emits, - which on regtest happens about every 10s after a wallet sync. A deselection that springs back on - within that window is that reload, not a failed tap; do the toggle steps back to back. - Tapping the amount on the amount screen swaps the primary display unit. Do not tap it. From 378f6df7bb95be7abd0ebfd6e04caa7df33dcc0e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 12:39:51 -0300 Subject: [PATCH 7/8] style: drop trailing comma after preview modifier Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index d44dcdc656..832de69171 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -342,7 +342,7 @@ private fun PreviewLoading() { uiState = CoinSelectionUiState( isLoading = true, ), - modifier = Modifier.sheetHeight(), + modifier = Modifier.sheetHeight() ) } } @@ -357,7 +357,7 @@ private fun PreviewLoadError() { uiState = CoinSelectionUiState( loadError = AppError("Node is not setup"), ), - modifier = Modifier.sheetHeight(), + modifier = Modifier.sheetHeight() ) } } From dd46737ce4bf3c17b3972b55a7d6a7a8b53b0c05 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 12:39:56 -0300 Subject: [PATCH 8/8] test: cover coin selection loading, error and list branches Co-Authored-By: Claude Opus 5 (1M context) --- .../send/SendCoinSelectionContentTest.kt | 105 ++++++++++++++++++ .../wallets/send/SendCoinSelectionScreen.kt | 12 +- 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionContentTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionContentTest.kt new file mode 100644 index 0000000000..08977300a4 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionContentTest.kt @@ -0,0 +1,105 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.SpendableUtxo +import to.bitkit.ext.uniqueUtxoKey +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.utils.AppError +import kotlin.test.assertTrue + +@ComposeUi +class SendCoinSelectionContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val utxo = SpendableUtxo(outpoint = OutPoint(txid = "abc123", vout = 0u), valueSats = 50_000uL) + + @Test + fun whenLoadingWithoutUtxos_shouldShowSpinnerOnly() { + composeTestRule.setContent { + SendCoinSelectionContent(uiState = CoinSelectionUiState(isLoading = true)) + } + + composeTestRule.onNodeWithTag("CoinSelectionLoading").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionLoadError").assertDoesNotExist() + composeTestRule.onNodeWithTag("CoinSelectionRetry").assertDoesNotExist() + } + + @Test + fun whenLoadFailsWithoutUtxos_shouldShowErrorWithRetryInsteadOfSpinner() { + composeTestRule.setContent { + SendCoinSelectionContent( + uiState = CoinSelectionUiState(loadError = AppError("Node is not setup")) + ) + } + + composeTestRule.onNodeWithTag("CoinSelectionLoadError").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionRetry").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionLoading").assertDoesNotExist() + } + + @Test + fun whenRetryingAfterError_shouldShowErrorWithoutSpinner() { + composeTestRule.setContent { + SendCoinSelectionContent( + uiState = CoinSelectionUiState( + isLoading = true, + loadError = AppError("Node is not setup"), + ) + ) + } + + composeTestRule.onNodeWithTag("CoinSelectionLoadError").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionLoading").assertDoesNotExist() + } + + @Test + fun whenRetryClicked_shouldTriggerEvent() { + var eventTriggered = false + composeTestRule.setContent { + SendCoinSelectionContent( + uiState = CoinSelectionUiState(loadError = AppError("Node is not setup")), + onRetry = { eventTriggered = true }, + ) + } + + composeTestRule.onNodeWithTag("CoinSelectionRetry").performClick() + + assertTrue(eventTriggered) + } + + @Test + fun whenUtxosLoaded_shouldShowListWithoutSpinnerOrError() { + composeTestRule.setContent { + SendCoinSelectionContent( + uiState = CoinSelectionUiState(availableUtxos = persistentListOf(utxo)) + ) + } + + composeTestRule.onNodeWithTag("utxo_row_${utxo.uniqueUtxoKey()}").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionLoading").assertDoesNotExist() + composeTestRule.onNodeWithTag("CoinSelectionLoadError").assertDoesNotExist() + } + + @Test + fun whenUtxosLoadedWhileReloading_shouldKeepListInsteadOfSpinner() { + composeTestRule.setContent { + SendCoinSelectionContent( + uiState = CoinSelectionUiState( + availableUtxos = persistentListOf(utxo), + isLoading = true, + ) + ) + } + + composeTestRule.onNodeWithTag("utxo_row_${utxo.uniqueUtxoKey()}").assertExists() + composeTestRule.onNodeWithTag("CoinSelectionLoading").assertDoesNotExist() + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index 832de69171..39718b37f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -85,7 +85,7 @@ fun SendCoinSelectionScreen( viewModel.setOnchainActivities(onchainActivities.orEmpty()) } - Content( + SendCoinSelectionContent( uiState = uiState, tagsByTxId = tagsByTxId, onBack = onBack, @@ -97,7 +97,7 @@ fun SendCoinSelectionScreen( } @Composable -private fun Content( +fun SendCoinSelectionContent( uiState: CoinSelectionUiState, modifier: Modifier = Modifier, tagsByTxId: ImmutableMap> = persistentMapOf(), @@ -290,7 +290,7 @@ private fun UtxoRow( private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content( + SendCoinSelectionContent( uiState = CoinSelectionUiState( availableUtxos = listOf( SpendableUtxo(outpoint = OutPoint(txid = "abc123", vout = 0u), valueSats = 50000uL), @@ -319,7 +319,7 @@ private fun Preview() { private fun PreviewEmpty() { AppThemeSurface { BottomSheetPreview { - Content( + SendCoinSelectionContent( uiState = CoinSelectionUiState( availableUtxos = persistentListOf(), totalRequiredSat = 1000uL, @@ -338,7 +338,7 @@ private fun PreviewEmpty() { private fun PreviewLoading() { AppThemeSurface { BottomSheetPreview { - Content( + SendCoinSelectionContent( uiState = CoinSelectionUiState( isLoading = true, ), @@ -353,7 +353,7 @@ private fun PreviewLoading() { private fun PreviewLoadError() { AppThemeSurface { BottomSheetPreview { - Content( + SendCoinSelectionContent( uiState = CoinSelectionUiState( loadError = AppError("Node is not setup"), ),