From 96becc3c37c20c151f9869dd4a0eadfd4e50f173 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 12:52:45 -0300 Subject: [PATCH 01/18] fix: pay first onchain subscription period in one swipe Co-Authored-By: Claude Opus 5.5 --- .../wallets/send/SendConfirmScreenTest.kt | 9 +++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 24 +++++++++++++++---- 3 files changed, 24 insertions(+), 11 deletions(-) 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 f15e5372bd..0b9401c0d4 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 @@ -155,7 +155,7 @@ class SendConfirmScreenTest { } @Test - fun initialOnchainSubscriptionShowsFeeBeforeConfirmation() { + fun initialOnchainSubscriptionPaysWithoutSecondSwipe() { val state = SendUiState( amount = 3_000u, payMethod = SendMethod.ONCHAIN, @@ -172,16 +172,13 @@ class SendConfirmScreenTest { isNodeRunning = true, isLoading = false, showBiometrics = false, - initialShowDetails = true, ) } } } - composeTestRule.onNodeWithTag("SendConfirmAssetButton").assertIsDisplayed() - composeTestRule.onNodeWithText("422", substring = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertIsDisplayed() - composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertIsDisplayed() + composeTestRule.onNodeWithTag("GRAB").assertDoesNotExist() + composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertDoesNotExist() } @Test diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 79d58fd4d2..d6eeaa8824 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -7016,7 +7016,7 @@ data class SendUiState( val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) { val shouldAutomaticallyPay: Boolean - get() = isInitialSubscriptionPayment && payMethod == SendMethod.LIGHTNING && hardwareWalletId == null + get() = isInitialSubscriptionPayment && hardwareWalletId == null } @Immutable diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6f0cbf83d4..bbc02d0695 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9342,7 +9342,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `initial onchain subscription payment requires confirmation`() = test { + fun `initial onchain subscription payment starts automatically`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) setSendState( SendUiState( @@ -9357,14 +9357,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) advanceUntilIdle() - assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) + assertTrue(sut.sendUiState.value.shouldAutomaticallyPay) assertFalse(sut.sendUiState.value.initialSubscriptionPaymentAutoStartPending) - assertFalse(sut.sendUiState.value.shouldConfirmPay) + assertTrue(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `initial hardware subscription payment requires confirmation`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + setSendState( + SendUiState( + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + hardwareWalletId = HARDWARE_WALLET_ID, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) - setSendState(sut.sendUiState.value.copy(payMethod = SendMethod.LIGHTNING)) sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) advanceUntilIdle() + assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) + assertFalse(sut.sendUiState.value.initialSubscriptionPaymentAutoStartPending) assertFalse(sut.sendUiState.value.shouldConfirmPay) } From bc2595a43b5b8b36be780b32c46429017b7d832f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 12:52:45 -0300 Subject: [PATCH 02/18] docs: update subscribe journey for single swipe Co-Authored-By: Claude Opus 5.5 --- journeys/README.md | 1 + journeys/subscriptions/review-and-subscribe.xml | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 0ab84680d3..5b34f63ffd 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -185,6 +185,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `backup/confirm-mnemonic-clear-wrong-word.xml` | not ported — `BackupConfirmMnemonic.swift` clears only the last word by its chip, with no red-word tap | | `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on | | `transfer/spending-confirm-amount-change.xml` | same file — both platforms show the same "Fees changed" info toast; iOS has no app-log step, and closing the received sheet re-sizes its Spending Confirm, so the lower amounts can show before the swipe | +| `subscriptions/review-and-subscribe.xml` | same file — Android sends a first payment due on acceptance in the same swipe for Lightning and on-chain savings; iOS still opens the send confirmation and asks for a second swipe when that payment is on-chain. Intentional for now, until iOS ports the single swipe | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 1132b667b5..9a3d65aa3b 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -2,8 +2,11 @@ Payer side of create-and-propose.xml. The proposed subscription arrives, surfaces under PROPOSALS, and opens the Review and Subscribe sheet where the payer swipes to accept. Accepting does not - return to the list on its own: when the first period is due on acceptance the send flow starts and - must be completed, otherwise the Subscribed confirmation opens directly. Both paths end on Close. + return to the list on its own: when the first period is due on acceptance the first payment starts + by itself, from Lightning or on-chain savings, with no second swipe; otherwise the Subscribed + confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send + confirmation for the first payment. Android differs from iOS here on purpose for now: iOS still + asks for a second swipe on the send confirmation when the first payment is on-chain. Run this on the payer instance immediately after the creator sends the proposal. @@ -15,8 +18,7 @@ Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card Verify the swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" Swipe the confirm control to the end - If the first period was due on acceptance: verify the sheet stays expanded as Review and Subscribe changes to the send flow, without closing, exposing Home or the subscriptions list, and sliding back in - If the first period was due on acceptance: complete the send flow inside the same sheet, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify a progress indicator replaces the review content with no swipe control, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending From fa90d60296b49bfec2687bb3201d18705e3bfcf0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 12:52:45 -0300 Subject: [PATCH 03/18] chore: add subscription single swipe changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/subscription-onchain-single-swipe.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/subscription-onchain-single-swipe.changed.md diff --git a/changelog.d/next/subscription-onchain-single-swipe.changed.md b/changelog.d/next/subscription-onchain-single-swipe.changed.md new file mode 100644 index 0000000000..a227b3d205 --- /dev/null +++ b/changelog.d/next/subscription-onchain-single-swipe.changed.md @@ -0,0 +1 @@ +Accepting a subscription now sends the first payment in the same swipe when it is paid on-chain, as it already did over Lightning. From ca5047cdb177fcf16be42470bc0649fd735c4b39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 14:25:01 -0300 Subject: [PATCH 04/18] fix: keep review layout while first subscription payment sends Co-Authored-By: Claude Opus 5.5 --- .../subscriptions/SubscriptionsScreen.kt | 30 ++++++++++++++++--- .../screens/wallets/send/SendConfirmScreen.kt | 20 +++++++++++++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 19 ++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 0fed380841..a65ff3e2dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -746,6 +746,25 @@ private val SubscriptionRoute.id: PaykitSubscriptionId is SubscriptionRoute.Cancel -> id } +/** Keeps the Review & Subscribe layout on screen while the first payment of an accepted subscription is sent. */ +@Composable +fun SubscriptionFirstPaymentProgress( + subscription: PaykitSubscription, + contact: PubkyProfile, + modifier: Modifier = Modifier, +) { + SubscriptionReview( + subscription = subscription, + contact = contact, + payOnAcceptance = true, + now = rememberSubscriptionNow(persistentListOf(subscription)), + onDetails = {}, + onSubscribe = { true }, + isPaying = true, + modifier = modifier + ) +} + @Composable private fun SubscriptionReview( subscription: PaykitSubscription, @@ -754,11 +773,13 @@ private fun SubscriptionReview( now: Instant, onDetails: () -> Unit, onSubscribe: suspend () -> Boolean, + modifier: Modifier = Modifier, + isPaying: Boolean = false, ) { - var loading by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(isPaying) } val scope = rememberCoroutineScope() Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .navigationBarsPadding() .padding(horizontal = 16.dp) @@ -798,7 +819,7 @@ private fun SubscriptionReview( modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally) ) FillHeight() - if (subscription.isProposalActionable(now)) { + if (loading || subscription.isProposalActionable(now)) { SwipeToConfirm( text = stringResource( if (payOnAcceptance) { @@ -809,6 +830,7 @@ private fun SubscriptionReview( ), color = Colors.Brand, loading = loading, + confirmed = isPaying, onConfirm = { loading = true scope.launch { @@ -1002,7 +1024,7 @@ private fun SubscriptionCancel( } } -private fun List.contactFor(subscription: PaykitSubscription): PubkyProfile = +internal fun List.contactFor(subscription: PaykitSubscription): PubkyProfile = firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, subscription.counterparty) } ?: PubkyProfile.placeholder(subscription.counterparty) 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 3805fe021c..6e56db7d08 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 @@ -126,6 +126,7 @@ fun SendConfirmScreen( onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, onNavigateToPin: () -> Unit, + autoPayContent: (@Composable () -> Unit)? = null, ) { val scope = rememberCoroutineScope() var isLoading by rememberSaveable { mutableStateOf(false) } @@ -188,6 +189,7 @@ fun SendConfirmScreen( showBiometrics = showBiometrics, canGoBack = canGoBack, initialShowDetails = uiState.isInitialSubscriptionPayment && !uiState.shouldAutomaticallyPay, + autoPayContent = autoPayContent, onBack = onBack, onEvent = onEvent, onClickAddTag = onClickAddTag, @@ -212,6 +214,21 @@ fun SendConfirmScreen( ) } +@Composable +private fun AutoPayOverlay( + isVisible: Boolean, + content: (@Composable () -> Unit)?, +) { + if (!isVisible || content == null) return + Box( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + ) { + content() + } +} + @Composable @Suppress("CyclomaticComplexMethod") internal fun SendConfirmContent( @@ -224,6 +241,7 @@ internal fun SendConfirmContent( preparingContact: PubkyProfile? = null, canGoBack: Boolean = true, initialShowDetails: Boolean = false, + autoPayContent: (@Composable () -> Unit)? = null, onBack: () -> Unit = {}, onEvent: (SendEvent) -> Unit = {}, onClickAddTag: () -> Unit = {}, @@ -289,6 +307,8 @@ internal fun SendConfirmContent( } } + AutoPayOverlay(isVisible = !isPreparing && isAutomaticPaymentLoading, content = autoPayContent) + if (showBiometrics && !isPreparing) { BiometricsView( onSuccess = onBiometricsSuccess, diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 99be199c81..6673de4802 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -54,7 +54,9 @@ import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionFirstPaymentProgress import to.bitkit.ui.screens.subscriptions.SubscriptionSuccess +import to.bitkit.ui.screens.subscriptions.contactFor import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.screens.wallets.send.HwSendSignScreen import to.bitkit.ui.screens.wallets.send.HwSendViewModel @@ -383,6 +385,15 @@ fun SendSheet( } val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val initialSubscription = uiState.incomingPaymentRequestId + ?.takeIf { uiState.isInitialSubscriptionPayment } + ?.let { id -> + subscriptions.firstOrNull { sub -> + sub.paymentRequestId == id.paymentRequestId && sub.counterparty == id.counterparty + } + } SendConfirmScreen( savedStateHandle = it.savedStateHandle, @@ -401,6 +412,14 @@ fun SendSheet( onClickAddTag = { navController.navigateTo(SendRoute.AddTag) }, onClickTag = { tag -> appViewModel.removeTag(tag) }, onNavigateToPin = { navController.navigateTo(SendRoute.PinCheck) }, + autoPayContent = initialSubscription?.let { subscription -> + { + SubscriptionFirstPaymentProgress( + subscription = subscription, + contact = contacts.contactFor(subscription), + ) + } + }, ) } composableWithDefaultTransitions { From ff6893c3aee277a28b776642ff8accb1931f78fa Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 14:25:01 -0300 Subject: [PATCH 05/18] docs: describe review layout during first subscription payment Co-Authored-By: Claude Opus 5.5 --- journeys/subscriptions/review-and-subscribe.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 9a3d65aa3b..cf0cb54b9e 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -18,7 +18,7 @@ Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card Verify the swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" Swipe the confirm control to the end - If the first period was due on acceptance: verify a progress indicator replaces the review content with no swipe control, wait for the payment to send, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending From 1a1068ffef25e531b72bac4489a2a61582c8d072 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 5 Oct 2026 15:02:29 -0300 Subject: [PATCH 06/18] fix: drop first billing period text from subscription review Co-Authored-By: Claude Opus 5.5 --- .../ui/screens/subscriptions/SubscriptionsScreen.kt | 11 ----------- app/src/main/res/values/strings.xml | 1 - 2 files changed, 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index a65ff3e2dc..f53aa4e774 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -791,17 +791,6 @@ private fun SubscriptionReview( MoneyDisplay(sats = subscription.displaySats, showSymbol = true) VerticalSpacer(24.dp) SubscriptionProviderCard(subscription, contact, onClick = onDetails) - subscription.paymentDueOnAcceptance(now, Clock.System.now())?.billingPeriod?.let { period -> - VerticalSpacer(16.dp) - BodyS( - text = stringResource( - R.string.subscriptions__first_period_ends, - dateTimeFormatterOf("MMM d, yyyy, HH:mm") - .format(java.time.Instant.ofEpochMilli(period.endsAt.toEpochMilliseconds())), - ), - color = Colors.White64, - ) - } if (!subscription.recurrence.unit.isSupported) { VerticalSpacer(16.dp) BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89937fc9cf..ae1a35d637 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1125,7 +1125,6 @@ Expires %1$s First Payment Failed You’re subscribed, but your first payment wasn’t sent. - First billing period ends %1$s. Each period is charged in full. Frequency Could not load this image. Choose another image and try again. Monthly From e6394d933396b72e1096a5c6d65311a7872e2a35 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 09:08:47 -0300 Subject: [PATCH 07/18] fix: label subscription review swipe as swipe to subscribe Co-Authored-By: Claude Opus 5.5 --- .../ui/screens/subscriptions/SubscriptionsScreen.kt | 12 +----------- journeys/subscriptions/review-and-subscribe.xml | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index f53aa4e774..c986f0b402 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -672,11 +672,9 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout VerticalSpacer(16.dp) } } else { - val payOnAcceptance = subscription.paymentDueOnAcceptance(now, Clock.System.now()) != null when (route) { is SubscriptionRoute.Review -> SubscriptionReview( subscription = subscription, - payOnAcceptance = payOnAcceptance, now = now, contact = contacts.contactFor(subscription), onDetails = { @@ -756,7 +754,6 @@ fun SubscriptionFirstPaymentProgress( SubscriptionReview( subscription = subscription, contact = contact, - payOnAcceptance = true, now = rememberSubscriptionNow(persistentListOf(subscription)), onDetails = {}, onSubscribe = { true }, @@ -769,7 +766,6 @@ fun SubscriptionFirstPaymentProgress( private fun SubscriptionReview( subscription: PaykitSubscription, contact: PubkyProfile, - payOnAcceptance: Boolean, now: Instant, onDetails: () -> Unit, onSubscribe: suspend () -> Boolean, @@ -810,13 +806,7 @@ private fun SubscriptionReview( FillHeight() if (loading || subscription.isProposalActionable(now)) { SwipeToConfirm( - text = stringResource( - if (payOnAcceptance) { - R.string.subscriptions__swipe_to_subscribe_and_pay - } else { - R.string.subscriptions__swipe_to_subscribe - } - ), + text = stringResource(R.string.subscriptions__swipe_to_subscribe), color = Colors.Brand, loading = loading, confirmed = isPaying, diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index cf0cb54b9e..33bbb74e86 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -16,7 +16,7 @@ Verify a row for "Journey Sub" appears under the PROPOSALS section Tap the "Journey Sub" row (testTag "SubscriptionRow-<paymentRequestId>") Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card - Verify the swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" + Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance Swipe the confirm control to the end If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow From 6d170818fd1c643839a6f3f3fddf7e19128b6afd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 09:08:47 -0300 Subject: [PATCH 08/18] fix: draw subscription review clock at figma scale and rotation Co-Authored-By: Claude Opus 5.5 --- .../screens/subscriptions/SubscriptionsScreen.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index c986f0b402..5ab3ca01e2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -637,6 +638,11 @@ private fun SubscriptionDetailFooter( } } +/** Figma draws the review clock at 288dp inside its 256dp slot, rotated 15 degrees and shifted 7dp left. */ +private const val CLOCK_SCALE = 288f / 256f +private const val CLOCK_ROTATION_DEGREES = 15f +private val CLOCK_OFFSET_X = (-7).dp + @Composable fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRoute) { var route by remember(initialRoute) { mutableStateOf(initialRoute) } @@ -801,7 +807,15 @@ private fun SubscriptionReview( Image( painter = painterResource(R.drawable.subscription_clock), contentDescription = null, - modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally) + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally) + .graphicsLayer { + scaleX = CLOCK_SCALE + scaleY = CLOCK_SCALE + rotationZ = CLOCK_ROTATION_DEGREES + translationX = CLOCK_OFFSET_X.toPx() + } ) FillHeight() if (loading || subscription.isProposalActionable(now)) { From f75f6c37205f4868cb0d0169d3be5a5ae8cbf39a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 10:57:31 -0300 Subject: [PATCH 09/18] docs: drop ios difference note from subscribe journey Co-Authored-By: Claude Opus 5.5 --- journeys/README.md | 1 - journeys/subscriptions/review-and-subscribe.xml | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 5b34f63ffd..0ab84680d3 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -185,7 +185,6 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `backup/confirm-mnemonic-clear-wrong-word.xml` | not ported — `BackupConfirmMnemonic.swift` clears only the last word by its chip, with no red-word tap | | `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on | | `transfer/spending-confirm-amount-change.xml` | same file — both platforms show the same "Fees changed" info toast; iOS has no app-log step, and closing the received sheet re-sizes its Spending Confirm, so the lower amounts can show before the swipe | -| `subscriptions/review-and-subscribe.xml` | same file — Android sends a first payment due on acceptance in the same swipe for Lightning and on-chain savings; iOS still opens the send confirmation and asks for a second swipe when that payment is on-chain. Intentional for now, until iOS ports the single swipe | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 33bbb74e86..1d6f2d46d8 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -5,8 +5,7 @@ return to the list on its own: when the first period is due on acceptance the first payment starts by itself, from Lightning or on-chain savings, with no second swipe; otherwise the Subscribed confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send - confirmation for the first payment. Android differs from iOS here on purpose for now: iOS still - asks for a second swipe on the send confirmation when the first payment is on-chain. + confirmation for the first payment. Run this on the payer instance immediately after the creator sends the proposal. From 7612cf9387c18974fdb17a17d1462f7612f2fc86 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 12:58:15 -0300 Subject: [PATCH 10/18] chore: name subscription changelog fragment after pr Co-Authored-By: Claude Opus 5.5 --- ...bscription-onchain-single-swipe.changed.md => 1422.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{subscription-onchain-single-swipe.changed.md => 1422.changed.md} (100%) diff --git a/changelog.d/next/subscription-onchain-single-swipe.changed.md b/changelog.d/next/1422.changed.md similarity index 100% rename from changelog.d/next/subscription-onchain-single-swipe.changed.md rename to changelog.d/next/1422.changed.md From c3b294402512983940e26c5e466c9cccce596e1b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:02:22 -0300 Subject: [PATCH 11/18] docs: keep subscription journeys valid with single swipe Co-Authored-By: Claude Opus 5.5 --- .../cancellation-during-confirmation.xml | 15 ++++++++++----- journeys/subscriptions/review-and-subscribe.xml | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/journeys/subscriptions/cancellation-during-confirmation.xml b/journeys/subscriptions/cancellation-during-confirmation.xml index 2b5a6fa5f1..ab503f7633 100644 --- a/journeys/subscriptions/cancellation-during-confirmation.xml +++ b/journeys/subscriptions/cancellation-during-confirmation.xml @@ -1,16 +1,21 @@ - Requires a linked regtest issuer and a payer funded for a 5,000-sat on-chain payment. + Requires a linked regtest issuer and a payer funded for two 5,000-sat on-chain payments. The issuer can cancel an accepted recurring request with a fixed P2WPKH endpoint and no - payment deadline. Verifies cancellation while confirmation is open. Cancellation after payment - preparation or during an authorization lookup is covered by unit tests. + payment deadline. Verifies cancellation while confirmation is open. The first period is paid by + the acceptance swipe itself, so the open confirmation is reached on the next period by advancing + the subscription clock offset. Cancellation after payment preparation or during an authorization + lookup is covered by unit tests. Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline and an explicit regtest P2WPKH endpoint - Accept the proposal in Bitkit and open its unpaid period to the send confirmation screen without confirming payment + Accept the proposal in Bitkit and wait for the first period's payment to send and the Subscribed confirmation to appear, then close it + Advance the subscription clock offset to the next monthly period in Dev Settings, then refresh Subscriptions + Open the next unpaid period to the send confirmation screen without confirming payment Have the issuer cancel the subscription and keep Bitkit foregrounded until the payer's request state or logs confirm receipt of the cancellation If the send confirmation is still open, try to confirm payment and verify it is refused - Verify no transaction to the request's receiving address was broadcast and no payment proof was created for the period + Verify no transaction for that period was broadcast to the request's receiving address and no payment proof was created for the period Return to Subscriptions and verify the canceled subscription offers no action to pay its unpaid period + Reset the subscription clock offset in Dev Settings diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 1d6f2d46d8..c2da0e4de6 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -9,12 +9,12 @@ Run this on the payer instance immediately after the creator sends the proposal. - Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact and a spendable balance above 5,000 sats + Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact, on-chain savings above 5,000 sats plus the network fee, and no usable Lightning balance, so a first payment is sent on-chain Have the creator instance send the "Journey Sub" proposal as described in create-and-propose.xml Open the drawer menu and tap Subscriptions Verify a row for "Journey Sub" appears under the PROPOSALS section Tap the "Journey Sub" row (testTag "SubscriptionRow-<paymentRequestId>") - Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card + Verify the Review and Subscribe sheet appears with 5,000 sats and the subscription card showing its name and cadence Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance Swipe the confirm control to the end If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears From 3db68468495462f73f43aa35c3cbb43d9e04f210 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:02:22 -0300 Subject: [PATCH 12/18] fix: keep first payment overlay inert and its swipe settled Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ui/components/SwipeToConfirm.kt | 8 +++++++- .../ui/screens/subscriptions/SubscriptionsScreen.kt | 13 +++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt index 1db7b12dd4..582c0b926e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -79,6 +79,7 @@ fun SwipeToConfirm( val maxPanX = if (swiperWidth == 0f) 1f else swiperWidth - with(LocalDensity.current) { CircleSize.toPx() } val panX = remember { Animatable(0f) } + val startedConfirmed = remember { confirmed } val loadingOpacity = remember { Animatable(0f) } val contentAlpha = if (enabled || loading) 1f else 0.5f @@ -90,8 +91,13 @@ fun SwipeToConfirm( } LaunchedEffect(confirmed, maxPanX) { + val target = if (confirmed) maxPanX else 0f + if (startedConfirmed && confirmed) { + panX.snapTo(target) + return@LaunchedEffect + } panX.animateTo( - targetValue = if (confirmed) maxPanX else 0f, + targetValue = target, animationSpec = spring() ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 5ab3ca01e2..ee8847c07d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -761,7 +761,7 @@ fun SubscriptionFirstPaymentProgress( subscription = subscription, contact = contact, now = rememberSubscriptionNow(persistentListOf(subscription)), - onDetails = {}, + onDetails = null, onSubscribe = { true }, isPaying = true, modifier = modifier @@ -773,7 +773,7 @@ private fun SubscriptionReview( subscription: PaykitSubscription, contact: PubkyProfile, now: Instant, - onDetails: () -> Unit, + onDetails: (() -> Unit)?, onSubscribe: suspend () -> Boolean, modifier: Modifier = Modifier, isPaying: Boolean = false, @@ -792,7 +792,7 @@ private fun SubscriptionReview( } MoneyDisplay(sats = subscription.displaySats, showSymbol = true) VerticalSpacer(24.dp) - SubscriptionProviderCard(subscription, contact, onClick = onDetails) + SubscriptionProviderCard(subscription, contact, onClick = onDetails, showAsCard = true) if (!subscription.recurrence.unit.isSupported) { VerticalSpacer(16.dp) BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) @@ -842,16 +842,17 @@ private fun SubscriptionProviderCard( contact: PubkyProfile, subtitle: String? = null, onClick: (() -> Unit)? = null, + showAsCard: Boolean = onClick != null, ) { val displayedSubtitle = subtitle ?: subscription.subscriptionFrequencyText() - val cardModifier = if (onClick == null) { + val cardModifier = if (!showAsCard) { Modifier.fillMaxWidth() } else { Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(Colors.Gray6) - .clickable(onClick = onClick) + .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier) .padding(16.dp) } Row( @@ -872,7 +873,7 @@ private fun SubscriptionProviderCard( overflow = TextOverflow.Ellipsis, ) } - if (onClick != null) { + if (showAsCard) { Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = Colors.White64) } } From 6e6a7fdd8eb846b78a9612f9f49fc0e60e9546d0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:02:22 -0300 Subject: [PATCH 13/18] refactor: reuse contacts and subscription lookup in send sheet Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 6673de4802..64ec4d2c62 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -50,6 +50,7 @@ import to.bitkit.models.SendFailureDetails import to.bitkit.models.WalletScope import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo @@ -385,15 +386,12 @@ fun SendSheet( } val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() - val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() - val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() - val initialSubscription = uiState.incomingPaymentRequestId + val initialSubscriptionId = uiState.incomingPaymentRequestId ?.takeIf { uiState.isInitialSubscriptionPayment } - ?.let { id -> - subscriptions.firstOrNull { sub -> - sub.paymentRequestId == id.paymentRequestId && sub.counterparty == id.counterparty - } - } + ?.let { id -> PaykitSubscriptionId(id.paymentRequestId, id.counterparty) } + val initialSubscription = remember(initialSubscriptionId) { + initialSubscriptionId?.let(appViewModel::subscription) + } SendConfirmScreen( savedStateHandle = it.savedStateHandle, From 23d21c75eda885773f2bad224c7e8a5e900a2a34 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:02:22 -0300 Subject: [PATCH 14/18] test: cover first payment overlay and fee warning Co-Authored-By: Claude Opus 5.5 --- .../wallets/send/SendConfirmScreenTest.kt | 67 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 35 ++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) 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 0b9401c0d4..0406dd27bf 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import com.synonym.paykit.PaymentRequestLifecycleState import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before @@ -36,9 +37,14 @@ import org.junit.Test import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHost +import to.bitkit.ui.screens.subscriptions.SubscriptionFirstPaymentProgress import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.AppThemeSurface @@ -47,6 +53,7 @@ import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.ExperimentalTime @HiltAndroidTest @@ -155,7 +162,65 @@ class SendConfirmScreenTest { } @Test - fun initialOnchainSubscriptionPaysWithoutSecondSwipe() { + fun initialOnchainSubscriptionKeepsReviewLayoutWhilePaying() { + val startsAt = Clock.System.now() + val subscription = PaykitSubscription( + paymentRequestId = "subscription-id", + counterparty = "payee", + amountValue = "0.00003", + amountSats = 3_000uL, + note = "Journey Sub", + createdAt = startsAt, + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = startsAt, + anchor = startsAt, + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("btc-regtest-p2wpkh"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) + val state = SendUiState( + amount = 3_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + onchainFeeUi = OnchainFeeUi(rate = FeeRate.NORMAL, sats = 422), + ) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendConfirmContent( + uiState = state, + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + autoPayContent = { + SubscriptionFirstPaymentProgress( + subscription = subscription, + contact = PubkyProfile.placeholder("payee"), + ) + }, + ) + } + } + } + + composeTestRule.onNodeWithText("Journey Sub").assertIsDisplayed() + composeTestRule.onNodeWithTag("GRAB").assertIsDisplayed() + composeTestRule.onNodeWithTag("GRAB").performTouchInput { swipeRight() } + composeTestRule.onNodeWithText("Journey Sub").assertIsDisplayed() + composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertDoesNotExist() + composeTestRule.onNodeWithText("Swipe To Subscribe & Pay").assertDoesNotExist() + } + + @Test + fun initialOnchainSubscriptionShowsProgressWhenSubscriptionIsMissing() { val state = SendUiState( amount = 3_000u, payMethod = SendMethod.ONCHAIN, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bbc02d0695..b0e47a5627 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9362,6 +9362,41 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `initial onchain subscription payment stops at the fee warning`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(600uL)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( + Result.success( + ConvertedAmount( + value = BigDecimal.ZERO, + formatted = "0.00", + symbol = "$", + currency = USD, + flag = "", + sats = 0, + ) + ) + ) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertEquals(SanityWarning.FEE_OVER_HALF_VALUE, sut.sendUiState.value.showSanityWarningDialog) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + @Test fun `initial hardware subscription payment requires confirmation`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) From ab5ae18ca4e6ad38f80ab9c15639512534c39705 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 08:22:21 -0300 Subject: [PATCH 15/18] fix: check first payment fee against preselected coins Co-Authored-By: Claude Opus 5.5 --- .../screens/wallets/send/SendConfirmScreen.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 26 ++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 76 +++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) 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 6e56db7d08..2ab742051e 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 @@ -182,6 +182,10 @@ fun SendConfirmScreen( currentOnEvent(SendEvent.StartInitialSubscriptionPayment) } + LaunchedEffect(uiState.initialSubscriptionPaymentNeedsConfirmation) { + if (uiState.initialSubscriptionPaymentNeedsConfirmation) isLoading = false + } + SendConfirmContent( uiState = uiState, isNodeRunning = isNodeRunning, @@ -353,7 +357,7 @@ private fun ContentRunning( onClickTag: (String) -> Unit = {}, onSwipeToConfirm: () -> Unit = {}, ) { - var showDetails by rememberSaveable { mutableStateOf(initialShowDetails) } + var showDetails by rememberSaveable(initialShowDetails) { mutableStateOf(initialShowDetails) } val swipeProgress = remember { mutableFloatStateOf(0f) } val isPreparing = preparingRequest != null val isLnurlPay = !isPreparing && uiState.lnurl is LnurlParams.LnurlPay diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d6eeaa8824..36b41ad74b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4477,7 +4477,27 @@ class AppViewModel @Inject constructor( if (!state.initialSubscriptionPaymentAutoStartPending) return _sendUiState.update { it.copy(initialSubscriptionPaymentAutoStartPending = false) } if (!state.shouldAutomaticallyPay) return - onSwipeToPay() + val generation = sendStateGeneration + viewModelScope.launch { + val isFeeKnown = awaitAutomaticOnchainFee() + if (generation != sendStateGeneration) return@launch + if (!isFeeKnown) { + _sendUiState.update { it.copy(initialSubscriptionPaymentNeedsConfirmation = true) } + return@launch + } + onSwipeToPay() + } + } + + private suspend fun awaitAutomaticOnchainFee(): Boolean { + if (_sendUiState.value.payMethod != SendMethod.ONCHAIN) return true + while (true) { + val job = onchainSendRefreshJob?.takeIf { it.isActive } ?: break + job.join() + } + val state = _sendUiState.value + if (state.onchainFeeUi.sats == null) return false + return state.selectedUtxos != null || !settingsStore.data.first().coinSelectAuto } private fun onCancelInitialSubscriptionPayment() { @@ -7013,10 +7033,12 @@ data class SendUiState( val isSubscriptionPayment: Boolean = false, val isInitialSubscriptionPayment: Boolean = false, val initialSubscriptionPaymentAutoStartPending: Boolean = false, + val initialSubscriptionPaymentNeedsConfirmation: Boolean = false, val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) { val shouldAutomaticallyPay: Boolean - get() = isInitialSubscriptionPayment && hardwareWalletId == null + get() = isInitialSubscriptionPayment && hardwareWalletId == null && + !initialSubscriptionPaymentNeedsConfirmation } @Immutable diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b0e47a5627..1ec5d2b7dd 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9349,6 +9349,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amount = 1_000u, payMethod = SendMethod.ONCHAIN, isAmountInputValid = true, + onchainFeeUi = OnchainFeeUi(sats = 150), + selectedUtxos = persistentListOf(mock()), isInitialSubscriptionPayment = true, initialSubscriptionPaymentAutoStartPending = true, ) @@ -9385,6 +9387,52 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amount = 1_000u, payMethod = SendMethod.ONCHAIN, isAmountInputValid = true, + onchainFeeUi = OnchainFeeUi(sats = 600), + selectedUtxos = persistentListOf(mock()), + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertEquals(SanityWarning.FEE_OVER_HALF_VALUE, sut.sendUiState.value.showSanityWarningDialog) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `initial onchain subscription payment checks the fee of the preselected coins`() = test { + val selectionStarted = CompletableDeferred() + val finishSelection = CompletableDeferred() + val selectedUtxos = persistentListOf(mock()) + whenever(lightningRepo.determineUtxosToSpend(any(), any())).doSuspendableAnswer { + selectionStarted.complete(Unit) + finishSelection.await() + selectedUtxos + } + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(150uL)) + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), eq(selectedUtxos), anyOrNull()) } + .thenReturn(Result.success(600uL)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( + Result.success( + ConvertedAmount( + value = BigDecimal.ZERO, + formatted = "0.00", + symbol = "$", + currency = USD, + flag = "", + sats = 0, + ) + ) + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + setUnifiedState(amount = 1_000u, payMethod = SendMethod.LIGHTNING) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + selectionStarted.await() + setSendState( + sut.sendUiState.value.copy( isInitialSubscriptionPayment = true, initialSubscriptionPaymentAutoStartPending = true, ) @@ -9392,11 +9440,39 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) advanceUntilIdle() + assertNull(sut.sendUiState.value.showSanityWarningDialog) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + + finishSelection.complete(Unit) + advanceUntilIdle() + assertEquals(selectedUtxos, sut.sendUiState.value.selectedUtxos) assertEquals(SanityWarning.FEE_OVER_HALF_VALUE, sut.sendUiState.value.showSanityWarningDialog) assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `initial onchain subscription payment requires confirmation when the fee is unknown`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.initialSubscriptionPaymentNeedsConfirmation) + assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + @Test fun `initial hardware subscription payment requires confirmation`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) From e7b424ecf940d4de75e7377fd966eaaddf717beb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 14:03:30 -0300 Subject: [PATCH 16/18] fix: keep manual confirmation after choosing coins for first payment Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 36b41ad74b..bfb3bd9e62 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4485,6 +4485,10 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(initialSubscriptionPaymentNeedsConfirmation = true) } return@launch } + val current = _sendUiState.value + if (current.payMethod == SendMethod.ONCHAIN && current.selectedUtxos == null) { + _sendUiState.update { it.copy(initialSubscriptionPaymentNeedsConfirmation = true) } + } onSwipeToPay() } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1ec5d2b7dd..24c9b31e57 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9473,6 +9473,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `initial onchain subscription payment requires confirmation after manual coin selection`() = test { + settingsData.value = SettingsData(coinSelectAuto = false) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + onchainFeeUi = OnchainFeeUi(sats = 141), + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + assertEquals(SendEffect.NavigateToCoinSelection, awaitItem()) + } + + assertTrue(sut.sendUiState.value.initialSubscriptionPaymentNeedsConfirmation) + assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + @Test fun `initial hardware subscription payment requires confirmation`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) From 37bcebcb170830c656997bc89a08b9a631e2b2a8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 14:35:25 -0300 Subject: [PATCH 17/18] fix: check first payment fee ratio without a usd rate Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index bfb3bd9e62..22a9f1b86c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4534,8 +4534,9 @@ class AppViewModel @Inject constructor( return } - val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() if ( + amountInUsd != null && amountInUsd.value > BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD) && settings.enableSendAmountWarning && SanityWarning.VALUE_OVER_100_USD !in _sendUiState.value.confirmedWarnings diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 24c9b31e57..1c0548b00c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9401,6 +9401,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `initial onchain subscription payment shows the fee warning without a usd rate`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(600uL)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("no rate"))) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + payMethod = SendMethod.ONCHAIN, + isAmountInputValid = true, + onchainFeeUi = OnchainFeeUi(sats = 600), + selectedUtxos = persistentListOf(mock()), + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertEquals(SanityWarning.FEE_OVER_HALF_VALUE, sut.sendUiState.value.showSanityWarningDialog) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + @Test fun `initial onchain subscription payment checks the fee of the preselected coins`() = test { val selectionStarted = CompletableDeferred() From f729fab83679c15d0d03d963e244dbd342e9f9ab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 16:25:44 -0300 Subject: [PATCH 18/18] docs: keep sheet handoff check in subscribe journey Co-Authored-By: Claude Opus 5.5 --- journeys/subscriptions/review-and-subscribe.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index c2da0e4de6..8d0c084c6f 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -17,7 +17,7 @@ Verify the Review and Subscribe sheet appears with 5,000 sats and the subscription card showing its name and cadence Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance Swipe the confirm control to the end - If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify the sheet stays expanded, without closing, exposing Home or the subscriptions list, and sliding back in, and the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending