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 0ec002a476..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 @@ -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 @@ -22,6 +24,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 +41,15 @@ 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.GradientCircularProgressIndicator 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 +61,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,23 +77,27 @@ 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) } - Content( + LaunchedEffect(onchainActivities) { + viewModel.setOnchainActivities(onchainActivities.orEmpty()) + } + + SendCoinSelectionContent( uiState = uiState, tagsByTxId = tagsByTxId, onBack = onBack, onContinue = { onContinue(uiState.selectedUtxos) }, onClickUtxo = { viewModel.onToggleUtxo(it) }, onRenderUtxo = { viewModel.loadTagsForUtxo(it) }, + onRetry = { viewModel.loadUtxos(requiredAmount, address) }, ) } @Composable -private fun Content( +fun SendCoinSelectionContent( uiState: CoinSelectionUiState, modifier: Modifier = Modifier, tagsByTxId: ImmutableMap> = persistentMapOf(), @@ -94,6 +105,7 @@ private fun Content( onContinue: () -> Unit = {}, onClickUtxo: (SpendableUtxo) -> Unit = {}, onRenderUtxo: (String) -> Unit = {}, + onRetry: () -> Unit = {}, ) { Column( modifier = modifier @@ -110,6 +122,29 @@ 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 + .fillParentMaxSize() + .testTag("CoinSelectionLoading") + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + } + + if (uiState.loadError != null && uiState.availableUtxos.isEmpty()) { + item { + LoadErrorState( + isLoading = uiState.isLoading, + onRetry = onRetry, + modifier = Modifier.fillParentMaxSize() + ) + } + } + // Utxo items items(uiState.availableUtxos) { utxo -> UtxoRow( @@ -161,6 +196,34 @@ private fun Content( } } +@Composable +private fun LoadErrorState( + isLoading: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + 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, @@ -227,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), @@ -256,7 +319,7 @@ private fun Preview() { private fun PreviewEmpty() { AppThemeSurface { BottomSheetPreview { - Content( + SendCoinSelectionContent( uiState = CoinSelectionUiState( availableUtxos = persistentListOf(), totalRequiredSat = 1000uL, @@ -269,3 +332,33 @@ private fun PreviewEmpty() { } } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewLoading() { + AppThemeSurface { + BottomSheetPreview { + SendCoinSelectionContent( + uiState = CoinSelectionUiState( + isLoading = true, + ), + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewLoadError() { + AppThemeSurface { + BottomSheetPreview { + SendCoinSelectionContent( + 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..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 @@ -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,14 @@ 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.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class SendCoinSelectionViewModel @Inject constructor( @@ -35,6 +40,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 +60,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 ServiceError.NodeNotSetup -> true + else -> false + } + fun loadTagsForUtxo(txId: String) { if (_tagsByTxId.value.containsKey(txId)) return @@ -148,4 +183,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 2eaaeb38cf..32abdd565f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1401,6 +1401,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..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 @@ -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,157 @@ 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 not running error`() = test { + whenever(lightningRepo.listSpendableOutputs()).thenReturn( + Result.failure(NodeNotRunningError("listSpendableOutputs", NodeLifecycleState.Stopped)), + Result.success(listOf(LARGE_UTXO)), + ) + stubFee() + + sut.loadUtxos(REQUIRED_AMOUNT, ADDRESS) + advanceUntilIdle() + + 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() + 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. diff --git a/journeys/README.md b/journeys/README.md index c711711637..788771150b 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -118,7 +118,7 @@ fixtures, push notifications) live in each suite's README. | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | -| [coin-selection](coin-selection) | 1 | Manual coin selection screen; needs 3+ on-chain UTXOs; no README | +| [coin-selection](coin-selection) | 2 | Manual coin selection: no Auto row, and the load/retry behaviour; needs 3+ on-chain UTXOs; 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 | | [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | @@ -159,6 +159,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | +| `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `receive/receive-auto-tab-selection.xml` | not ported — the Auto tab override fix is Android-only so far; iOS parity not checked | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | 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..5e6aa4c770 --- /dev/null +++ b/journeys/coin-selection/manual-coin-selection-load.xml @@ -0,0 +1,41 @@ + + + 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 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 + + 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.