diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 77da7be4ae..8df160fece 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -61,6 +61,7 @@ class MnemonicInputFieldTest { onPositionChange = {}, onBackspaceInEmpty = { backspaceInEmptyCount++ }, focusRequester = focusRequester, + isFocused = false, index = 0, ) } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt index 20e55fd28d..9b33631f3d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt @@ -1,15 +1,28 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kotlin.test.assertEquals +import kotlin.test.assertTrue import org.junit.Rule import org.junit.Test import to.bitkit.models.FeeRate +import to.bitkit.models.PubkyProfile import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.OnchainFeeUi @@ -48,6 +61,61 @@ class SendConfirmScreenTest { composeTestRule.onNodeWithTag("SendConfirmAssetButton").assertIsDisplayed() composeTestRule.onNodeWithText("422", substring = true).assertIsDisplayed() composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertIsDisplayed() - composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertIsDisplayed() + } + + @Test + fun paymentRequestKeepsAmountAndSwipeVisibleOnCompactScreen() { + val state = SendUiState( + amount = 1_753u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + isPaymentRequest = true, + contactPaymentProfile = PubkyProfile.placeholder("requester").copy(name = "Popup requester"), + paymentRequestNote = "A long invoice note. ".repeat(13).take(256), + onchainFeeUi = OnchainFeeUi(rate = FeeRate.NORMAL, sats = 141), + ) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalDensity provides Density(LocalDensity.current.density, fontScale = 1.3f), + ) { + Box(modifier = Modifier.size(width = 360.dp, height = 400.dp)) { + SendConfirmContent( + uiState = state, + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + ) + } + } + } + } + + val amount = composeTestRule.onNodeWithTag("ReviewAmount") + val swipe = composeTestRule.onNodeWithTag("GRAB") + amount.assertIsDisplayed() + swipe.assertIsDisplayed() + val amountBounds = amount.getUnclippedBoundsInRoot() + val swipeBounds = swipe.getUnclippedBoundsInRoot() + + val contentBounds = composeTestRule.onNodeWithTag("SendConfirmContent").getUnclippedBoundsInRoot() + composeTestRule.onNodeWithTag("PaymentRequestFrom").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestFor").performScrollTo().assertIsDisplayed() + for (tag in listOf("PaymentRequestFrom", "PaymentRequestFor")) { + val bounds = composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + assertTrue(bounds.top >= contentBounds.top) + assertTrue(bounds.bottom <= contentBounds.bottom) + } + + composeTestRule.onNodeWithTag("SendConfirmToggleDetails").performClick() + composeTestRule.onNodeWithTag("PaymentRequestInvoiceNote").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("SendConfirmContent").performTouchInput { swipeUp() } + + amount.assertIsDisplayed() + swipe.assertIsDisplayed() + assertEquals(amountBounds, amount.getUnclippedBoundsInRoot()) + assertEquals(swipeBounds, swipe.getUnclippedBoundsInRoot()) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index d7bf38d175..44a83033ff 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -332,7 +334,6 @@ private fun ContentRunning( modifier = modifier .padding(horizontal = 16.dp) .fillMaxSize() - .verticalScroll(rememberScrollState()) ) { BalanceHeaderView( sats = uiState.amount.toLong(), @@ -344,55 +345,68 @@ private fun ContentRunning( .testTag("ReviewAmount") ) - VerticalSpacer(if (uiState.isOneOffPaymentRequest && !isLnurlPay) 24.dp else 44.dp) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("SendConfirmContent") + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + ) { + VerticalSpacer(if (uiState.isOneOffPaymentRequest && !isLnurlPay) 24.dp else 44.dp) - if (isLnurlPay) { - LnurlPayDetails(uiState = uiState, onEvent = onEvent) - } else if (showDetails) { - when (uiState.payMethod) { - SendMethod.ONCHAIN -> { - OnChainDetails( - uiState = uiState, - interactionsEnabled = !isHardwareFeeLoading, - onEvent = onEvent, - ) - VerticalSpacer(16.dp) - TagsSection(uiState, onClickTag, onClickAddTag) - uiState.oneOffPaymentRequestNote?.let { + if (isLnurlPay) { + LnurlPayDetails(uiState = uiState, onEvent = onEvent) + } else if (showDetails) { + when (uiState.payMethod) { + SendMethod.ONCHAIN -> { + OnChainDetails( + uiState = uiState, + interactionsEnabled = !isHardwareFeeLoading, + onEvent = onEvent, + ) + VerticalSpacer(16.dp) + TagsSection(uiState, onClickTag, onClickAddTag) + uiState.oneOffPaymentRequestNote?.let { + VerticalSpacer(16.dp) + PaymentRequestInvoiceNote(note = it) + } + } + + SendMethod.LIGHTNING -> { + LightningDetails( + uiState = uiState, + onEvent = onEvent, + onClickTag = onClickTag, + onClickAddTag = onClickAddTag, + ) + } + } + } else { + if (uiState.isOneOffPaymentRequest) { + PaymentRequestSummary(uiState = uiState, iconColor = accentColor) VerticalSpacer(16.dp) - PaymentRequestInvoiceNote(note = it) } - } - - SendMethod.LIGHTNING -> { - LightningDetails( - uiState = uiState, - onEvent = onEvent, - onClickTag = onClickTag, - onClickAddTag = onClickAddTag, + Image( + painter = painterResource(R.drawable.coin_stack_4), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(IMAGE_FILL_PERCENTAGE) + .weight(1f) + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + .graphicsLayer { rotationZ = swipeProgress.floatValue * SWIPE_ROTATION_DEGREES } ) } } - } else { - if (uiState.isOneOffPaymentRequest) { - PaymentRequestSummary(uiState = uiState, iconColor = accentColor) - VerticalSpacer(16.dp) - } - Image( - painter = painterResource(R.drawable.coin_stack_4), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth(IMAGE_FILL_PERCENTAGE) - .align(Alignment.CenterHorizontally) - .padding(bottom = 16.dp) - .graphicsLayer { rotationZ = swipeProgress.floatValue * SWIPE_ROTATION_DEGREES } - ) } if (!isLnurlPay) { - FillHeight(min = 16.dp) - PrimaryButton( text = stringResource( if (showDetails) R.string.common__hide_details else R.string.common__show_details @@ -423,12 +437,10 @@ private fun ContentRunning( .align(Alignment.CenterHorizontally) .testTag("SendConfirmToggleDetails") ) - - VerticalSpacer(62.dp) - } else { - FillHeight(min = 16.dp) } + VerticalSpacer(16.dp) + SwipeToConfirm( text = stringResource( if (uiState.isInitialSubscriptionPayment) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 473605d250..9d70a988d1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -970,13 +970,21 @@ class AppViewModel @Inject constructor( viewModelScope.launch { paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> retainPaymentRequestPresentationState(requests) - val activeRequest = activeIncomingPaymentRequest() ?: return@collect - if (isSubmittingPaymentRequest || uncertainOnchainPaymentRequestId == activeRequest.id) return@collect + val activeRequest = activeIncomingPaymentRequest() if ( - currentSheet.value is Sheet.Send && - requests.none { it.id == activeRequest.id } + activeRequest != null && + !isSubmittingPaymentRequest && + uncertainOnchainPaymentRequestId != activeRequest.id ) { - hideSheet() + if (currentSheet.value is Sheet.Send && requests.none { it.id == activeRequest.id }) { + hideSheet() + } + } + if ( + isPaykitEnabled.value && paymentRequestIdentity != null && + PubkyPublicKeyFormat.matches(paymentRequestIdentity, pubkyRepo.publicKey.value) + ) { + presentNextIncomingPaykitPaymentRequest() } } } @@ -1028,10 +1036,10 @@ class AppViewModel @Inject constructor( activePaymentRequestPresentationGeneration = null } + if (isPaymentRequestPresentationBlocked()) return if ( - stopped && - generation != paymentRequestPresentationGeneration && - !isPaymentRequestPresentationBlocked() + stopped && generation != paymentRequestPresentationGeneration || + paymentRequestsForPresentation()?.any { next -> requests.none { it.id == next.id } } == true ) { presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a8d49e8e82..e1971da6d7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -923,6 +923,70 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `new pending request opens without another refresh or resume`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + val bolt11 = "lnbcrt1newpendingrequest" + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + clearInvocations(paykitPaymentRequestRepo) + + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).refresh() + } + + @Test + fun `request arriving during another presentation is not left waiting for refresh`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + sut.showPaymentRequests() + runCurrent() + val firstRequest = paymentRequest() + val nextRequest = paymentRequest().copy(paymentRequestId = "next-request") + val firstResolutionStarted = CompletableDeferred() + val finishFirstResolution = CompletableDeferred() + whenever(privatePaykitRepo.beginPaymentRequest(firstRequest)).doSuspendableAnswer { + firstResolutionStarted.complete(Unit) + finishFirstResolution.await() + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + } + val bolt11 = "lnbcrt1nextpendingrequest" + stubOpenedPaymentRequest(nextRequest, bolt11) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(firstRequest) + runCurrent() + sut.hideSheet() + firstResolutionStarted.await() + + pendingPaykitPaymentRequests.value = listOf(firstRequest, nextRequest) + runCurrent() + finishFirstResolution.complete(Unit) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(nextRequest.id, sut.sendUiState.value.incomingPaymentRequestId) + verify(privatePaykitRepo).beginPaymentRequest(nextRequest) + } + @Test fun `opened request passes its note to the confirm sheet`() = test { sut.setIsAuthenticated(true) diff --git a/changelog.d/next/1405.fixed.md b/changelog.d/next/1405.fixed.md new file mode 100644 index 0000000000..e76358f56e --- /dev/null +++ b/changelog.d/next/1405.fixed.md @@ -0,0 +1 @@ +New incoming Payment Requests open automatically while Bitkit is unlocked. The payment amount and confirmation slider stay visible while details scroll. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 52cd3b856f..f6c0b4074f 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -70,3 +70,5 @@ request id, check its full row identifier, and include the required back and tab transitions. The accepted subscription must have no end date so cancellation is available. The proposal review must explain that its payment details are unsupported and offer no Subscribe control. + +`automatic-presentation.xml` uses the same two-wallet setup and leaves the payer in the foreground. `confirmation-controls.xml` checks Android's fixed amount and confirmation footer on a compact screen with large text; it is not ported to iOS because the confirmation layout is different there. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml new file mode 100644 index 0000000000..47b5009cdd --- /dev/null +++ b/journeys/payment-requests/automatic-presentation.xml @@ -0,0 +1,20 @@ + + + Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded for 21,000 sats; see README.md. + + + On the payer, leave Bitkit unlocked and in the foreground on Home + On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" + On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + Close the confirmation without paying + On the payer, open Receive and leave its sheet open + On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" + Wait for the requester's send operation to finish and for the payer to synchronize its pending requests + On the payer, verify Receive stays open without being replaced by confirmation + Close Receive + Verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" + Close the confirmation without paying + Open Receive and close it again + Verify Home remains visible and neither reviewed request reopens automatically + + diff --git a/journeys/payment-requests/confirmation-controls.xml b/journeys/payment-requests/confirmation-controls.xml new file mode 100644 index 0000000000..16750d258b --- /dev/null +++ b/journeys/payment-requests/confirmation-controls.xml @@ -0,0 +1,18 @@ + + + Verifies that the amount and Swipe To Pay remain visible while the requester summary and confirmation details scroll on a compact screen with large text. Requires a linked requester and a payer funded for 1,753 sats plus fees. Configure the payer at 360 dp width and 1.3 font scale before opening Bitkit. This journey covers Android's confirmation layout; iOS has a separate layout. + + + On the requester, send the payer contact a Payment Request for 1,753 sats with a multi-paragraph invoice note + On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") opens automatically + Verify the full 1,753-sat amount (testTag "ReviewAmount"), Show details and Swipe To Pay (testTag "GRAB") are visible without scrolling + Verify From and For remain reachable by scrolling the middle content (testTag "SendConfirmContent") if the requester summary does not fit + Tap Show details (testTag "SendConfirmToggleDetails") + Scroll the middle confirmation content (testTag "SendConfirmContent") to the invoice note (testTag "PaymentRequestInvoiceNote") + Verify the full amount and Swipe To Pay remain visible in their original positions above the system navigation area + Scroll the details back to the top + Verify the full amount, Hide details and Swipe To Pay remain visible + Tap Hide details and verify the amount and Swipe To Pay remain visible + Close the confirmation without paying + + diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 8259e8b5a7..c4da618fdf 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -4,7 +4,7 @@ On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" - On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 21,000 sats + On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 21,000 sats without opening the pending Payment Requests bell Verify From (testTag "PaymentRequestFrom") shows the requester's contact name Verify For (testTag "PaymentRequestFor") shows "Lunch last week" Tap Show details (testTag "SendConfirmToggleDetails") @@ -12,7 +12,7 @@ Verify the invoice note (testTag "PaymentRequestInvoiceNote") shows "Lunch last week" Close the Payment Request confirmation screen without paying On the requester, send the payer contact a Payment Request for 5,000 sats with no note - On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 5,000 sats, opening it from the pending Payment Requests bell (testTag "PaymentRequestsBell") if it does not open by itself + On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears automatically with 5,000 sats Verify From (testTag "PaymentRequestFrom") shows the requester's contact name and For (testTag "PaymentRequestFor") shows "Not specified"