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
63 changes: 50 additions & 13 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendEffect>(extraBufferCapacity = 1)
val sendEffect = _sendEffect.asSharedFlow()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Comment thread
ovitrif marked this conversation as resolved.
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) {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
161 changes: 158 additions & 3 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
ovitrif marked this conversation as resolved.
enablePaykitUi()
advanceUntilIdle()
sut.setIsAuthenticated(true)
Expand All @@ -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<Unit>()
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
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1542.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions journeys/pubky-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading