diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b541e2a233..7902d61d36 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -341,6 +341,7 @@ class AppViewModel @Inject constructor( private val deferredScanLock = Any() private var deferredScan: DeferredScan? = null + private var scanAfterPubkyAuth: DeferredScan? = null private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() @@ -411,6 +412,7 @@ class AppViewModel @Inject constructor( val subscriptions = paykitPaymentRequestRepo.subscriptions val pubkyContacts = pubkyRepo.contacts private var sheetTransitionJob: Job? = null + private var transitionSheet: Sheet? = null private var paymentRequestSheetTransitionJob: Job? = null private var queuedPairingCodeRequestId: Long? = null private var receiveSheetContext: ReceiveSheetContext? = null @@ -568,9 +570,8 @@ class AppViewModel @Inject constructor( private fun markDeferredScanUnlocked() { synchronized(deferredScanLock) { - val queued = deferredScan ?: return - if (queued.suppressQuickPay) return - deferredScan = queued.copy(suppressQuickPay = true) + deferredScan = deferredScan?.copy(suppressQuickPay = true) + scanAfterPubkyAuth = scanAfterPubkyAuth?.copy(suppressQuickPay = true) } } @@ -1894,6 +1895,9 @@ class AppViewModel @Inject constructor( if (targetsRequest(deferredScan?.contactPaymentContext)) { deferredScan = null } + if (targetsRequest(scanAfterPubkyAuth?.contactPaymentContext)) { + scanAfterPubkyAuth = null + } } val shouldHideRequestSendSheet = if (requestId != null) { synchronized(contactPaymentContextLock) { @@ -3055,6 +3059,11 @@ class AppViewModel @Inject constructor( return null } + if (isPubkyAuthSheetShownOrPending() && !deferrable.isPubkyAuth) { + enqueueDeferredScan(deferrable) + return null + } + val previousJob = scheduled?.job val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { clearPaymentRequestPreparation(previousPreparation) @@ -3189,7 +3198,7 @@ class AppViewModel @Inject constructor( if (_isAuthenticated.value) return false synchronized(deferredScanLock) { - if (deferredScan == null) { + if (deferredScan == null || (scan.isPubkyAuth && deferredScan?.isPubkyAuth == false)) { enqueueDeferredScan(scan) } else { Logger.info( @@ -3218,13 +3227,23 @@ class AppViewModel @Inject constructor( synchronized(deferredScanLock) { val queued = deferredScan if (queued?.data?.removeLightningSchemes() == normalized) { - val suppressQuickPay = scan.suppressQuickPay || queued.suppressQuickPay - if (scan.contactPaymentContext != null) { - deferredScan = scan.copy(suppressQuickPay = suppressQuickPay) + deferredScan = queued.mergeDuplicate(scan, scanId) + return + } + if (!scan.isPubkyAuth && (queued?.isPubkyAuth == true || scanAfterPubkyAuth != null)) { + val followUp = scanAfterPubkyAuth + if (followUp?.data?.removeLightningSchemes() == normalized) { + scanAfterPubkyAuth = followUp.mergeDuplicate(scan, scanId) return } - deferredScan = queued.copy(suppressQuickPay = suppressQuickPay) - Logger.info("Skipping duplicate queued scan from '${scan.source.label}': '$scanId'", context = TAG) + scanAfterPubkyAuth = scan + Logger.info("Queuing '${scan.source.label}' scan after a sign-in link: '$scanId'", context = TAG) + return + } + if (queued != null && scan.isPubkyAuth && !queued.isPubkyAuth) { + scanAfterPubkyAuth = queued + deferredScan = scan + Logger.info("Queuing '${scan.source.label}' sign-in link first: '$scanId'", context = TAG) return } if (queued != null) { @@ -3239,7 +3258,16 @@ class AppViewModel @Inject constructor( Logger.info("Queuing '${scan.source.label}' scan for deferred handling: '$scanId'", context = TAG) } - private fun hasDeferredScan() = synchronized(deferredScanLock) { deferredScan != null } + private fun DeferredScan.mergeDuplicate(scan: DeferredScan, scanId: String): DeferredScan { + val suppressQuickPay = scan.suppressQuickPay || this.suppressQuickPay + if (scan.contactPaymentContext != null) return scan.copy(suppressQuickPay = suppressQuickPay) + Logger.info("Skipping duplicate queued scan from '${scan.source.label}': '$scanId'", context = TAG) + return copy(suppressQuickPay = suppressQuickPay) + } + + private fun hasDeferredScan() = synchronized(deferredScanLock) { + deferredScan != null || scanAfterPubkyAuth != null + } private fun isScanPendingOrActive(): Boolean { if (scheduledScan?.job?.isActive == true) return true @@ -3262,7 +3290,7 @@ class AppViewModel @Inject constructor( if (_currentSheet.value != null) return val pending = synchronized(deferredScanLock) { - deferredScan.also { deferredScan = null } + deferredScan?.also { deferredScan = null } ?: scanAfterPubkyAuth.also { scanAfterPubkyAuth = null } } ?: run { viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } return @@ -6601,6 +6629,7 @@ class AppViewModel @Inject constructor( fun showSheet(sheetType: Sheet) { if (sheetType !== paymentRequestPreparation.value?.sheet) clearPaymentRequestPreparation() val previousJob = sheetTransitionJob + transitionSheet = sheetType val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null if (_currentSheet.value != null && !replacesSubscriptionReview(sheetType)) { @@ -6620,12 +6649,18 @@ class AppViewModel @Inject constructor( } sheetTransitionJob = nextJob nextJob.invokeOnCompletion { - if (sheetTransitionJob === nextJob) sheetTransitionJob = null + if (sheetTransitionJob === nextJob) { + sheetTransitionJob = null + transitionSheet = null + } } previousJob?.cancel() nextJob.start() } + private fun isPubkyAuthSheetShownOrPending() = _currentSheet.value is Sheet.PubkyAuth || + (sheetTransitionJob?.isActive == true && transitionSheet is Sheet.PubkyAuth) + private fun replacesSubscriptionReview(sheet: Sheet): Boolean { if (sheet !is Sheet.Send || !_sendUiState.value.isInitialSubscriptionPayment) return false val review = (_currentSheet.value as? Sheet.Subscription)?.route as? SubscriptionRoute.Review ?: return false @@ -7828,7 +7863,9 @@ private data class DeferredScan( val contactPaymentContext: ContactPaymentContext?, val allowPubkyAuth: Boolean, val suppressQuickPay: Boolean, -) +) { + val isPubkyAuth: Boolean get() = allowPubkyAuth && PubkyAuthRequest.isProtocolUrl(data) +} // region send contract @Stable diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c1978c8339..edf9e6d373 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4814,7 +4814,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `new locked payment scan supersedes a pubky auth deeplink waiting for identity`() = test { + fun `locked payment scan runs after a pubky auth deeplink waiting for identity`() = test { enablePaykitUi() advanceUntilIdle() sut.setIsAuthenticated(true) @@ -4840,10 +4840,135 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() + assertEquals(Sheet.PubkyAuth("pubkyauth://signin_grant"), sut.currentSheet.value) + + sut.hideSheet() + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(bolt11, sut.sendUiState.value.addressInput) + } + + @Test + fun `links opened on the PIN screen open the sign-in first and the payment once after it`() = test { + enablePaykitUi() + advanceUntilIdle() + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val bolt11 = "lnbcrt1pinscreenscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + val authUrl = "pubkyauth://signin_grant" + listOf(listOf(authUrl, "lightning:$bolt11"), listOf("lightning:$bolt11", authUrl)).forEach { links -> + sut.setIsAuthenticated(true) + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + advanceUntilIdle() + + links.forEach { sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, it.toUri())) } + advanceUntilIdle() + assertNull(sut.currentSheet.value) + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + sut.hideSheet() + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(bolt11, sut.sendUiState.value.addressInput) + sut.hideSheet() + advanceUntilIdle() + assertNull(sut.currentSheet.value) + } + } + + @Test + fun `newer sign-in link opened on the PIN screen wins over an older waiting one`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val older = "pubkyauth://signin_grant?caps=/pub/older/:rw" + val newer = "pubkyauth://signin_grant?caps=/pub/newer/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, older.toUri())) + runCurrent() + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + runCurrent() + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, newer.toUri())) + runCurrent() + pubkyPublicKey.value = testPublicKey + initialized.complete(Unit) + advanceUntilIdle() + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(newer), sut.currentSheet.value) + } + + @Test + fun `newer payment link replaces the one queued behind a pending sign-in`() = test { + enablePaykitUi() + advanceUntilIdle() + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val older = "lnbcrt1olderscan" + val newer = "lnbcrt1newerscan" + stubLightningScan(bolt11 = older, amountSats = 500u) + stubLightningScan(bolt11 = newer, amountSats = 600u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + val authUrl = "pubkyauth://signin_grant" + sut.setIsAuthenticated(true) + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + advanceUntilIdle() + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$older".toUri())) + advanceUntilIdle() + sut.setIsAuthenticated(true) + advanceUntilIdle() + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$newer".toUri())) + advanceUntilIdle() + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + + sut.hideSheet() + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(newer, sut.sendUiState.value.addressInput) + sut.hideSheet() + advanceUntilIdle() + assertNull(sut.currentSheet.value) + } + + @Test + fun `payment deeplink waits until the open pubky auth sheet closes`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val bolt11 = "lnbcrt1authsheetscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceUntilIdle() + assertEquals(Sheet.PubkyAuth("pubkyauth://signin_grant"), sut.currentSheet.value) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + assertEquals(Sheet.PubkyAuth("pubkyauth://signin_grant"), sut.currentSheet.value) + + sut.hideSheet() + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(bolt11, sut.sendUiState.value.addressInput) - verify(pubkyRepo, never()).hasSecretKey() - verify(toastManager, never()).enqueue(any()) } @Test @@ -7779,6 +7904,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `payment deferred by the lock and reopened over the pubky auth sheet skips QuickPay`() = test { + val bolt11 = "lnbcrt1authsheetquickpay" + enableQuickPay() + enablePaykitUi() + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + advanceUntilIdle() + sut.setIsAuthenticated(true) + sut.lockOnBackground() + advanceUntilIdle() + val authUrl = "pubkyauth://signin_grant" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + sut.setIsAuthenticated(true) + advanceUntilIdle() + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `latest locked scan replaces earlier input`() = test { val first = "lnbcrt1lockedfirst" diff --git a/changelog.d/next/1542.fixed.md b/changelog.d/next/1542.fixed.md new file mode 100644 index 0000000000..0b65b52f50 --- /dev/null +++ b/changelog.d/next/1542.fixed.md @@ -0,0 +1 @@ +Opening another link while a Pubky sign-in is pending no longer drops the sign-in; the other link opens after it. diff --git a/journeys/README.md b/journeys/README.md index cc08811f4b..78347624e5 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -173,6 +173,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | | `pubky-auth/signin-cold-start.xml` | `pubky-auth/open-signin-link.xml`, adapted — iOS opens the same sign-in link on a terminated app through `bitkit://pubky-auth/signin`. Android only: the raw `pubkyauth` scheme, the "Loading Your Pubky Profile" toast while the session restores, and Authorize through to `PubkyAuthOK` | +| `pubky-auth/signin-with-second-link.xml` | not ported — iOS still drops a pending sign-in link when a second link opens on the PIN screen (synonymdev/bitkit-ios#985); port it with that fix | | `pubky-profile/cached-profile-header.xml` | same file, journey name and steps — one step differs: Android adds a screenshot check of the cached avatar, which comes from the Pubky image disk cache synonymdev/bitkit-android#1399 adds | | `pubky-profile/ring-choice-rows.xml` | same file, journey name and steps — Android names the rows and their lookup spinners differently (see [Identifiers](#identifiers)), checks that rows are disabled by tapping another row instead of reading the snapshot's Targets, needs no second tap on the profile button, and names the import overview by its "Import" title | | `pubky-profile/contact-import-after-leaving.xml` | same file, journey name and steps — Android names the Ring rows differently and has no identifiers on the import overview's profile and friend count, so those steps name the screen text here; see [Identifiers](#identifiers). synonymdev/bitkit-android#1399 adds `ContactImportOverviewImportAll`, the only contact import testTag the journeys use, under its iOS name | diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 0917a92dfb..2dc901383e 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -9,6 +9,9 @@ `PubkyAuthOK`. A restore that fails while the device is offline needs the device network toggled, which the journey environment does not provide, so that case is a manual test. +- `signin-with-second-link.xml` checks that a payment link opened while a sign-in link is pending, + on the PIN screen in either order or over the open authorization sheet, opens only after the + authorization sheet closes, and never drops the sign-in. ## Grant signup diff --git a/journeys/pubky-auth/signin-with-second-link.xml b/journeys/pubky-auth/signin-with-second-link.xml new file mode 100644 index 0000000000..edfa81aa39 --- /dev/null +++ b/journeys/pubky-auth/signin-with-second-link.xml @@ -0,0 +1,34 @@ + + + Verifies that a payment link opened while a sign-in link is pending does not drop the sign-in: + the authorization sheet opens first, and the payment opens once the sheet closes. Covers a + payment link opened on the PIN screen in both orders, and one opened while the authorization + sheet is open. Precondition: a wallet with the PIN on, Paykit enabled and a locally managed + Pubky identity, and a requesting app that supplies a fresh pubkyauth://signin_grant URL for each + case. Not ported to iOS yet: iOS still drops the pending sign-in link (synonymdev/bitkit-ios#985). + + + Run adb shell am force-stop to.bitkit.dev + Run adb shell am start -a android.intent.action.VIEW -d "<a fresh signin_grant URL>" to.bitkit.dev + Verify the PIN pad is visible (testTag "PinPad") + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev + Enter the PIN with the number pad keys + Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Press back to close the authorization sheet + Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats, then press back until the wallet home screen is visible + Run adb shell am force-stop to.bitkit.dev + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev + Verify the PIN pad is visible (testTag "PinPad") + Run adb shell am start -a android.intent.action.VIEW -d "<a fresh signin_grant URL>" to.bitkit.dev + Enter the PIN with the number pad keys + Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Press back to close the authorization sheet + Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats, then press back until the wallet home screen is visible + Run adb shell am start -a android.intent.action.VIEW -d "<a fresh signin_grant URL>" to.bitkit.dev + Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev + Verify the authorization sheet is still visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Press back to close the authorization sheet + Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats + +