diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt new file mode 100644 index 0000000000..afc4135b2e --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt @@ -0,0 +1,99 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +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 to.bitkit.ext.createChannelDetails +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.WalletState +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +@ComposeUi +class ReceiveAutoTabSelectionTest { + @get:Rule + val composeTestRule = createComposeRule() + + private var lightningState by mutableStateOf(STATE_WITHOUT_INBOUND) + private val editedTabs = mutableListOf() + + @Test + fun keepsTabPickedByTapWhenAutoBecomesAvailable() { + setContent() + + composeTestRule.onNodeWithTag("Tab-spending").performClick() + composeTestRule.onNodeWithTag("Tab-savings").performClick() + composeTestRule.waitForIdle() + + makeAutoAvailable() + + assertEquals(ReceiveTab.SAVINGS, selectedTab()) + } + + @Test + fun jumpsToAutoWhenNoTabWasPicked() { + setContent() + composeTestRule.waitForIdle() + + makeAutoAvailable() + + assertEquals(ReceiveTab.AUTO, selectedTab()) + } + + private fun setContent() { + composeTestRule.setContent { + AppThemeSurface { + ReceiveQrScreen( + cjitInvoice = null, + walletState = WALLET_STATE, + lightningState = lightningState, + onClickEditInvoice = { editedTabs += it }, + onClickReceiveCjit = {}, + ) + } + } + } + + private fun makeAutoAvailable() { + composeTestRule.runOnIdle { lightningState = STATE_WITH_INBOUND } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("Tab-auto").assertIsDisplayed() + } + + private fun selectedTab(): ReceiveTab { + composeTestRule.onAllNodesWithTag("SpecifyInvoiceButton")[0].performClick() + composeTestRule.waitForIdle() + return editedTabs.last() + } + + private companion object { + const val ADDRESS = "bcrt1qreceiveaddress" + const val BOLT11 = "lnbcrt1invoice" + val WALLET_STATE = WalletState( + onchainAddress = ADDRESS, + bolt11 = BOLT11, + bip21 = "bitcoin:$ADDRESS?lightning=$BOLT11", + ) + val STATE_WITHOUT_INBOUND = LightningState(nodeLifecycleState = NodeLifecycleState.Running) + val STATE_WITH_INBOUND = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = persistentListOf( + createChannelDetails().copy( + isChannelReady = true, + isUsable = true, + inboundCapacityMsat = 100_000_000u, + ) + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 5a9e04890b..f54bd86c4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -188,6 +189,7 @@ fun ReceiveQrScreen( } var hasAppliedInitialTab by remember { mutableStateOf(false) } var appliedInitialTab by remember { mutableStateOf(null) } + var hasUserSelectedTab by remember { mutableStateOf(false) } LaunchedEffect(visibleTabs, initialTab) { val requestedTab = initialTab?.takeIf { it in visibleTabs } @@ -227,18 +229,28 @@ fun ReceiveQrScreen( } } - // Auto-switch to AUTO tab when it becomes available for the first time - LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { - val shouldAutoSwitch = initialTab == null && canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() - if (shouldAutoSwitch && visibleTabs.contains(ReceiveTab.AUTO)) { - val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) - if (autoIndex != -1) { - lazyListState.animateScrollToItem(autoIndex) - selectedTab = ReceiveTab.AUTO - } + LaunchedEffect(lazyListState) { + lazyListState.interactionSource.interactions.collect { + if (it is DragInteraction.Start) hasUserSelectedTab = true } } + // Jump to AUTO tab without animation when it becomes available before the user picks a tab + LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + val shouldAutoSwitch = shouldAutoSwitchToAuto( + selectedTab = selectedTab, + hasUserSelectedTab = hasUserSelectedTab, + canCreateLightningInvoice = canCreateLightningInvoice, + cjitInvoice = cjitInvoice, + initialTab = initialTab, + ) + if (!shouldAutoSwitch) return@LaunchedEffect + val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) + if (autoIndex == -1 || lazyListState.firstVisibleItemIndex == autoIndex) return@LaunchedEffect + lazyListState.scrollToItem(autoIndex) + selectedTab = ReceiveTab.AUTO + } + // Auto-switch to Spending tab when CJIT is not null LaunchedEffect(cjitInvoice) { if (cjitInvoice != null) { @@ -301,6 +313,7 @@ fun ReceiveQrScreen( onTabChange = { tab -> haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) val newIndex = visibleTabs.indexOf(tab) + hasUserSelectedTab = true selectedTab = tab showDetails = false scope.launch { @@ -478,6 +491,18 @@ private fun List.defaultReceiveTab(): ReceiveTab { return if (contains(ReceiveTab.AUTO)) ReceiveTab.AUTO else ReceiveTab.SAVINGS } +internal fun shouldAutoSwitchToAuto( + selectedTab: ReceiveTab, + hasUserSelectedTab: Boolean, + canCreateLightningInvoice: Boolean, + cjitInvoice: String?, + initialTab: ReceiveTab?, +): Boolean { + if (initialTab != null || hasUserSelectedTab) return false + if (selectedTab == ReceiveTab.AUTO) return false + return canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReceiveQrView( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..914896aca9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4723,6 +4723,8 @@ class AppViewModel @Inject constructor( onchainAddress = walletRepo.getOnchainAddress(), ) } + // A stale amount above inbound liquidity would hide the Auto tab until the receive state refreshes + if (sheetType is Sheet.Receive) walletRepo.setBip21AmountSats(null) _currentSheet.update { sheetType } } sheetTransitionJob = nextJob diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt new file mode 100644 index 0000000000..c6d7bdcbbe --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt @@ -0,0 +1,57 @@ +package to.bitkit.ui.screens.wallets.receive + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReceiveAutoTabSwitchTest { + + @Test + fun `switches when lightning becomes available and user has not selected a tab`() { + assertTrue(decide()) + } + + @Test + fun `does not switch when auto is already selected`() { + assertFalse(decide(selectedTab = ReceiveTab.AUTO)) + } + + @Test + fun `does not switch after user selected a tab`() { + assertFalse(decide(hasUserSelectedTab = true)) + } + + @Test + fun `does not switch when lightning invoice cannot be created`() { + assertFalse(decide(canCreateLightningInvoice = false)) + } + + @Test + fun `does not switch when cjit invoice exists`() { + assertFalse(decide(cjitInvoice = "lnbcrt1cjit")) + } + + @Test + fun `switches when cjit invoice is empty`() { + assertTrue(decide(cjitInvoice = "")) + } + + @Test + fun `does not switch when an initial tab was requested`() { + assertFalse(decide(initialTab = ReceiveTab.SPENDING)) + } + + private fun decide( + selectedTab: ReceiveTab = ReceiveTab.SAVINGS, + hasUserSelectedTab: Boolean = false, + canCreateLightningInvoice: Boolean = true, + cjitInvoice: String? = null, + initialTab: ReceiveTab? = null, + ) = shouldAutoSwitchToAuto( + selectedTab = selectedTab, + hasUserSelectedTab = hasUserSelectedTab, + canCreateLightningInvoice = canCreateLightningInvoice, + cjitInvoice = cjitInvoice, + initialTab = initialTab, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..6ebf7254d0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4135,6 +4135,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `showSheet clears stale receive amount before presenting the receive sheet`() = test { + var sheetWhenCleared: Sheet? = Sheet.Send() + whenever(walletRepo.setBip21AmountSats(null)).thenAnswer { sheetWhenCleared = sut.currentSheet.value } + + val sheet = Sheet.Receive() + sut.showSheet(sheet) + advanceUntilIdle() + + verify(walletRepo).setBip21AmountSats(null) + assertNull(sheetWhenCleared) + assertEquals(sheet, sut.currentSheet.value) + } + + @Test + fun `showSheet keeps receive amount when presenting another sheet`() = test { + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + verify(walletRepo, never()).setBip21AmountSats(anyOrNull()) + } + @Test fun `received lightning payment closes the active receive sheet after wallet invoice is cleared`() = test { walletState.value = WalletState(bolt11 = "settled-invoice") diff --git a/changelog.d/next/1308.fixed.md b/changelog.d/next/1308.fixed.md new file mode 100644 index 0000000000..839d2144d0 --- /dev/null +++ b/changelog.d/next/1308.fixed.md @@ -0,0 +1 @@ +Fixed the Receive sheet sliding to the Auto tab on open and overriding a tab you had already picked. diff --git a/journeys/README.md b/journeys/README.md index 18d1a5c654..c48ad16a57 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -122,6 +122,7 @@ fixtures, push notifications) live in each suite's README. | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | +| [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [widgets](widgets) | 2 | Needs no backend — the quickest way to see the loop work; no README | @@ -146,6 +147,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `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 | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/receive/receive-auto-tab-selection.xml b/journeys/receive/receive-auto-tab-selection.xml new file mode 100644 index 0000000000..c70f767362 --- /dev/null +++ b/journeys/receive/receive-auto-tab-selection.xml @@ -0,0 +1,87 @@ + + + Verifies that the Receive sheet opens on the Auto tab without sliding to it, that a tab the user + picks is not overridden by the Auto tab, and that a leftover invoice amount from an earlier Edit + Invoice session does not open the sheet on Savings. Requires a wallet with a usable spending + channel so the Auto tab is available once the Lightning node is running. The selected tab is + shown only by its underline, which `android layout` does not expose, so tab assertions need a + screenshot; the Auto tab's presence can be read from the testTag "Tab-auto". The underline itself + crossfades over 200 ms even when the switch is instant, so the two steps that check for an + animation read the QR pager out of a screen recording instead of the underline. Each of those + steps first requires the recording to contain the frames it reasons about; a recording that ended + before the node started, or before the sheet opened, is a rerun with a longer `--time-limit`, not + a pass. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the Receive button (testTag "Receive"), take a screenshot within one second, and verify the + Auto tab (testTag "Tab-auto") is selected on the first frame of the sheet + + + Swipe the QR code area from left to right once and verify the Savings tab (testTag + "Tab-savings") is selected + + + Wait 3 seconds, take a screenshot and verify the Savings tab is still selected + + + Close the sheet, then run `adb shell am force-stop to.bitkit.dev` and launch the app again + + + As soon as the home screen shows, tap the Receive button and verify the tab row has no Auto tab + (testTag "Tab-auto" absent) while the Lightning node starts + + + Tap the Trezor tab (testTag "Tab-trezor") if present, then tap the Savings tab (testTag + "Tab-savings") + + + Wait until the Auto tab (testTag "Tab-auto") appears, wait 5 more seconds, take a screenshot and + verify the Savings tab is still selected + + + Close the sheet, force-stop and relaunch the app, and start `adb shell screenrecord + --time-limit 20 /sdcard/receive-auto.mp4`. As soon as the home screen shows, tap the Receive + button without touching the tabs and verify the Auto tab (testTag "Tab-auto") is absent; do not + wait for the node, the tab row must still be Savings-first when the sheet opens + + + Wait until the Auto tab (testTag "Tab-auto") appears, and check that it appeared while the + recording was still running. The node has to finish `lightningService.start` and load its + channels before the Auto tab exists, which on a cold process can outlast the 20 second window; + if the Auto tab is still absent when the recording ends, the jump happened off-tape and the run + holds no evidence, so repeat the previous step with a longer `--time-limit` rather than reading + the empty recording as a pass + + + Let the recording finish, pull it (`adb pull /sdcard/receive-auto.mp4`), extract frames (`ffmpeg + -i receive-auto.mp4 -vf fps=30 frames/%03d.png`) and verify first that the recording contains + the jump at all: a frame showing the Savings page centred in the QR pager, followed by a later + frame showing the Auto page centred. If either frame is missing, the step did not capture the + switch and has to be rerun; it is not a pass. Then verify that no frame between those two shows + the QR pager part-way between the Savings and the Auto page: the sheet jumps to Auto in one + frame instead of sliding + + + Tap "Edit" (testTag "SpecifyInvoiceButton"), tap the amount field (testTag + "ReceiveNumberPadTextField"), enter an amount above the inbound Lightning capacity (for example + N3 N0 N0 N0 in USD), tap Continue (testTag "ReceiveNumberPadSubmit") and then "QR Code" (testTag + "ShowQrReceive"); verify the Auto tab (testTag "Tab-auto") is absent + + + Start `adb shell screenrecord --time-limit 20 /sdcard/receive-reopen.mp4`, close the sheet and + tap the Receive button again, then verify the Auto tab (testTag "Tab-auto") is present + + + Let the recording finish, pull it (`adb pull /sdcard/receive-reopen.mp4`), extract frames + (`ffmpeg -i receive-reopen.mp4 -vf fps=30 frames/%03d.png`) and verify that the recording holds + frames in which the sheet is visible — if it holds none, rerun the step, it is not a pass — then + that the first such frame already shows the Auto tab in the tab row with the selected underline, + and that no frame shows Savings selected. A sheet that opens on Savings and only then gains the + Auto tab means the leftover invoice amount was not cleared + + +