Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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<ReceiveTab>()

@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,
)
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -188,6 +189,7 @@ fun ReceiveQrScreen(
}
var hasAppliedInitialTab by remember { mutableStateOf(false) }
var appliedInitialTab by remember { mutableStateOf<ReceiveTab?>(null) }
var hasUserSelectedTab by remember { mutableStateOf(false) }

LaunchedEffect(visibleTabs, initialTab) {
val requestedTab = initialTab?.takeIf { it in visibleTabs }
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -478,6 +491,18 @@ private fun List<ReceiveTab>.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(
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jvsena42 marked this conversation as resolved.
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,
)
}
22 changes: 22 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1308.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed the Receive sheet sliding to the Auto tab on open and overriding a tab you had already picked.
2 changes: 2 additions & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

Expand Down
87 changes: 87 additions & 0 deletions journeys/receive/receive-auto-tab-selection.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<journey name="receive auto tab selection">
<description>
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.
</description>
<actions>
<action>
Launch the Bitkit app and go to the wallet home screen
</action>
<action>
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
</action>
<action>
Swipe the QR code area from left to right once and verify the Savings tab (testTag
"Tab-savings") is selected
</action>
<action>
Wait 3 seconds, take a screenshot and verify the Savings tab is still selected
</action>
<action>
Close the sheet, then run `adb shell am force-stop to.bitkit.dev` and launch the app again
</action>
<action>
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
</action>
<action>
Tap the Trezor tab (testTag "Tab-trezor") if present, then tap the Savings tab (testTag
"Tab-savings")
</action>
<action>
Wait until the Auto tab (testTag "Tab-auto") appears, wait 5 more seconds, take a screenshot and
verify the Savings tab is still selected
</action>
<action>
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
</action>
<action>
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
</action>
<action>
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
</action>
<action>
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
</action>
<action>
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
</action>
<action>
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
</action>
</actions>
</journey>
Loading