Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class MnemonicInputFieldTest {
onPositionChange = {},
onBackspaceInEmpty = { backspaceInEmptyCount++ },
focusRequester = focusRequester,
isFocused = false,
index = 0,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
Comment thread
ben-kaufman marked this conversation as resolved.
)
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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -332,7 +334,6 @@ private fun ContentRunning(
modifier = modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
BalanceHeaderView(
sats = uiState.amount.toLong(),
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 16 additions & 8 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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()
}
Expand Down
64 changes: 64 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit>()
val finishFirstResolution = CompletableDeferred<Unit>()
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)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1405.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New incoming Payment Requests open automatically while Bitkit is unlocked. The payment amount and confirmation slider stay visible while details scroll.
2 changes: 2 additions & 0 deletions journeys/payment-requests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions journeys/payment-requests/automatic-presentation.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<journey name="Automatic Payment Request Presentation">
<description>
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.
</description>
<actions>
<action>On the payer, leave Bitkit unlocked and in the foreground on Home</action>
<action>On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request"</action>
<action>On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request"</action>
<action>Close the confirmation without paying</action>
<action>On the payer, open Receive and leave its sheet open</action>
<action>On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request"</action>
<action>Wait for the requester's send operation to finish and for the payer to synchronize its pending requests</action>
<action>On the payer, verify Receive stays open without being replaced by confirmation</action>
<action>Close Receive</action>
<action>Verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request"</action>
<action>Close the confirmation without paying</action>
<action>Open Receive and close it again</action>
<action>Verify Home remains visible and neither reviewed request reopens automatically</action>
</actions>
</journey>
Loading
Loading