diff --git a/app/src/main/java/to/bitkit/models/Toast.kt b/app/src/main/java/to/bitkit/models/Toast.kt index 4ff2cd760d..c4bed3e31d 100644 --- a/app/src/main/java/to/bitkit/models/Toast.kt +++ b/app/src/main/java/to/bitkit/models/Toast.kt @@ -7,11 +7,15 @@ data class Toast( val autoHide: Boolean, val visibilityTime: Long = VISIBILITY_TIME_DEFAULT, val testTag: String? = null, + val isLoading: Boolean = false, ) { enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR } companion object { const val VISIBILITY_TIME_SHORT = 1500L const val VISIBILITY_TIME_DEFAULT = 3000L + + /** Visibility for a toast with an action the user must read before it hides. */ + const val VISIBILITY_TIME_LONG = 6000L } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 93ed5efc3c..bb5a2360ca 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1527,6 +1527,11 @@ class PubkyRepo @Inject constructor( keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name) } + /** Whether Bitkit stores its own key for the saved identity, read without restoring the session. */ + suspend fun hasLocalSecretKey(): Boolean = withContext(ioDispatcher) { + keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name) && !keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name) + } + suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) { _publicKey.value != null || !keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() || diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index ba19d236f6..4328f3505d 100644 --- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -21,7 +21,11 @@ import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton -/** Advertises Pubky signup and authorization handlers when their required identity state is available. */ +/** + * Advertises Pubky signup and authorization handlers when their required identity state is available. + * While a saved session is not restored yet, a secret key Bitkit stores itself keeps the authorization handler on, so a + * sign-in link can wait for the restore instead of being dropped. Approval still checks the restored identity's key. + */ @Singleton internal class PubkyAuthHandlerRegistrar @Inject constructor( @ApplicationContext private val context: Context, @@ -59,7 +63,7 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( .onFailure { Logger.warn("Failed to read saved Pubky identity", it, context = TAG) } .getOrDefault(true) val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled) - val hasSecretKey = isPaykitUiEnabled && publicKey != null && pubkyRepo.hasSecretKey() + val hasSecretKey = isPaykitUiEnabled && hasLocalSecretKey(publicKey) val authEnabled = canHandlePubkyAuth(isPaykitUiEnabled, hasIdentity, hasSecretKey) authEnabled to (isPaykitUiEnabled && !hasIdentity) } @@ -71,6 +75,13 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( } } + private suspend fun hasLocalSecretKey(publicKey: String?): Boolean { + if (publicKey != null) return pubkyRepo.hasSecretKey() + return runSuspendCatching { pubkyRepo.hasLocalSecretKey() } + .onFailure { Logger.warn("Failed to read saved Pubky secret key", it, context = TAG) } + .getOrDefault(false) + } + private fun setAliasEnabled(component: ComponentName, enabled: Boolean) { val state = if (enabled) { diff --git a/app/src/main/java/to/bitkit/ui/components/ToastView.kt b/app/src/main/java/to/bitkit/ui/components/ToastView.kt index 08bbee6c7e..3eee0aa6a9 100644 --- a/app/src/main/java/to/bitkit/ui/components/ToastView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ToastView.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset @@ -177,10 +178,21 @@ fun ToastView( .fillMaxWidth() .padding(16.dp) ) { - BodyMSB( - text = toast.title, - color = tintColor, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (toast.isLoading) { + GradientCircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + } + BodyMSB( + text = toast.title, + color = tintColor, + ) + } toast.description?.let { description -> Caption( text = description, @@ -312,6 +324,16 @@ private fun ToastViewPreview() { ), onDismiss = {}, ) + ToastView( + toast = Toast( + type = Toast.ToastType.INFO, + title = "Loading Your Pubky Profile", + description = "Your sign-in request opens when your profile is ready.", + autoHide = false, + isLoading = true, + ), + onDismiss = {}, + ) ToastView( toast = Toast( type = Toast.ToastType.ERROR, diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt index 94af44aa3d..eabf11320d 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt @@ -37,17 +37,14 @@ class ToastQueueManager(private val scope: CoroutineScope) { private var isPaused = false /** - * Add toast to queue. If queue is full, drops oldest. + * Add toast to queue. If queue is full, drops oldest. A shown or queued loading toast returns after the new one. */ fun enqueue(toast: Toast) { + val interruptedLoading = _currentToast.value?.takeIf { it.isLoading } _queue.update { current -> - val newQueue = if (current.size >= MAX_QUEUE_SIZE) { - // Drop oldest (first item) when queue full - current.drop(1) + toast - } else { - current + toast - } - newQueue + val (loading, others) = current.partition { it.isLoading } + // Drop oldest when queue full + (others + toast + loading + listOfNotNull(interruptedLoading)).takeLast(MAX_QUEUE_SIZE) } dismissCurrentToast() } @@ -63,6 +60,14 @@ class ToastQueueManager(private val scope: CoroutineScope) { showNextToastIfAvailable() } + /** + * Hide the given toast, if shown or still queued, and leave any other toast in place. + */ + fun dismiss(toast: Toast) { + if (_currentToast.value === toast) return dismissCurrentToast() + _queue.update { queue -> queue.filterNot { it === toast } } + } + /** * Pause current toast timer (called on drag start). */ diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 61bb8a4436..bac97e6a70 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -199,6 +199,7 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyIdentityReadiness import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PubkySignIn import to.bitkit.repositories.PublicPaykitPaymentResult @@ -336,6 +337,7 @@ class AppViewModel @Inject constructor( @Volatile private var scheduledScan: ScheduledScan? = null + private var pubkyAuthWaitToast: Toast? = null private val deferredScanLock = Any() private var deferredScan: DeferredScan? = null @@ -3064,11 +3066,15 @@ class AppViewModel @Inject constructor( val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { clearPaymentRequestPreparation(previousPreparation) scanMutex.withLock { - if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock - if (deferLockedScan(deferrable)) return@withLock - prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) - if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay) + try { + if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock + if (deferLockedScan(deferrable)) return@withLock + prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) + if (startDelay > Duration.ZERO) delay(startDelay) + handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay) + } finally { + hidePubkyAuthWaitToast() + } } } val nextScheduledScan = ScheduledScan( @@ -3115,13 +3121,20 @@ class AppViewModel @Inject constructor( } if (!PubkyAuthRequest.isSignupUrl(data) || PubkyAuthRequest.isGrantSignupUrl(data)) { - val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { + if (!isContactLink) showPubkyAuthWaitToastIfRestoring() + val timeout = if (isContactLink) PubkyService.AUTHORIZATION_TIMEOUT else PUBKY_AUTH_DEEPLINK_TIMEOUT + val isInitializationReady = withTimeoutOrNull(timeout) { pubkyRepo.awaitInitialization() - if (!isContactLink) pubkyRepo.awaitIdentityReady() + if (!isContactLink) awaitPubkyAuthIdentity() awaitContactDataForDeeplink(isContactLink) } ?: false if (!isInitializationReady) { Logger.warn("Failed to initialize Pubky deeplink", context = TAG) + if (!isContactLink) { + hidePubkyAuthWaitToast() + showPubkyIdentityUnavailableToast() + return false + } ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString( @@ -3141,6 +3154,36 @@ class AppViewModel @Inject constructor( return isPaykitUiEnabledFromSettings() && walletRepo.walletExists() } + private suspend fun awaitPubkyAuthIdentity() { + while (pubkyRepo.awaitIdentityReady() == PubkyIdentityReadiness.Unavailable) { + delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL) + isOnline.first { it == ConnectivityState.CONNECTED } + Logger.debug("Retrying Pubky session restore for a sign-in link", context = TAG) + } + } + + private suspend fun showPubkyAuthWaitToastIfRestoring() { + if (pubkyRepo.publicKey.value != null) return + if (!isPaykitUiEnabledFromSettings() || !walletRepo.walletExists()) return + if (!runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(false)) return + val waitToast = Toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.pubky_auth__loading_identity), + description = context.getString(R.string.pubky_auth__loading_identity_desc), + autoHide = false, + testTag = PUBKY_AUTH_WAIT_TOAST_TAG, + isLoading = true, + ) + pubkyAuthWaitToast = waitToast + toastManager.enqueue(waitToast) + } + + private fun hidePubkyAuthWaitToast() { + val waitToast = pubkyAuthWaitToast ?: return + pubkyAuthWaitToast = null + toastManager.dismiss(waitToast) + } + private suspend fun awaitContactDataForDeeplink(isContactLink: Boolean): Boolean { if (!isContactLink || pubkyRepo.publicKey.value == null) return true return pubkyRepo.refreshContacts() @@ -7629,6 +7672,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.pubky_auth__identity_unavailable), description = context.getString(R.string.pubky_auth__identity_unavailable_desc), + visibilityTime = Toast.VISIBILITY_TIME_LONG, ) return } @@ -7725,6 +7769,15 @@ class AppViewModel @Inject constructor( private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL = 120.seconds private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes + /** Longest wait for the saved Pubky session before a sign-in link fails. */ + internal val PUBKY_AUTH_DEEPLINK_TIMEOUT = 60.seconds + + /** Pause between Pubky session restore attempts while a sign-in link waits. */ + private val PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL = 3.seconds + + /** Test tag of the toast shown while a sign-in link waits for the Pubky session. */ + internal const val PUBKY_AUTH_WAIT_TOAST_TAG = "PubkyAuthWaitToast" + private const val BITKIT_SCHEME = "bitkit" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb93e82778..0076334f3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -748,6 +748,8 @@ Homeserver Couldn\'t Load Your Pubky Profile Check your connection and try again. + Loading Your Pubky Profile + Your sign-in request opens when your profile is ready. Pubky Identity Required Create a Pubky identity in your profile to approve auth requests. Create a new Pubky identity on this homeserver. Only continue if you trust it. diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4f379d4058..23aadaf43f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1772,6 +1772,19 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(result.getOrNull()) } + @Test + fun `hasLocalSecretKey is true only for a stored key without a Ring source`() = test { + whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(true) + assertTrue(sut.hasLocalSecretKey()) + + whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true) + assertFalse(sut.hasLocalSecretKey()) + + whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(false) + whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(false) + assertFalse(sut.hasLocalSecretKey()) + } + @Test fun `adoptRingIdentity should reject a mismatching credential without changing the source`() = test { val ringPubky = VALID_SELF_KEY.removePrefix("pubky") diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index 8e5f659d6d..1447fe2eb2 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -55,6 +55,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { whenever(pubkyRepo.backupStateVersion).thenReturn(backupStateVersion) whenever(pubkyRepo.identityRefreshVersion).thenReturn(identityRefreshVersion) whenever(pubkyRepo.hasIdentity()).thenAnswer { publicKey.value != null } + whenever(pubkyRepo.hasLocalSecretKey()).thenReturn(false) } @Test @@ -150,6 +151,26 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { verifyComponentStates(authEnabled = false, signupEnabled = true) } + @Test + fun `failed restore keeps the handler on for a saved locally managed identity`() = test { + isPaykitEnabled.value = true + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.hasLocalSecretKey()).thenReturn(true) + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentStates(authEnabled = true, signupEnabled = false) + verify(pubkyRepo, never()).hasSecretKey() + + clearInvocations(packageManager) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + publicKey.value = "pubkylocal" + runCurrent() + + verify(packageManager, never()).setComponentEnabledSetting(any(), any(), any()) + } + @Test fun `saved identity suppresses signup during restoration failure until explicitly removed`() = test { isPaykitEnabled.value = true diff --git a/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueManagerTest.kt b/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueManagerTest.kt new file mode 100644 index 0000000000..e3962d86a0 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueManagerTest.kt @@ -0,0 +1,66 @@ +package to.bitkit.ui.shared.toast + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import org.junit.Test +import to.bitkit.models.Toast +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class ToastQueueManagerTest : BaseUnitTest() { + private val waitToast = Toast(type = Toast.ToastType.INFO, title = "Loading", autoHide = false, isLoading = true) + private val errorToast = Toast(type = Toast.ToastType.ERROR, title = "Failed", autoHide = false) + + @Test + fun `dismiss hides the given toast when it is shown`() = test { + val sut = ToastQueueManager(backgroundScope) + sut.enqueue(waitToast) + + sut.dismiss(waitToast) + + assertNull(sut.currentToast.value) + } + + @Test + fun `dismiss keeps a toast that replaced the given one`() = test { + val sut = ToastQueueManager(backgroundScope) + sut.enqueue(waitToast) + sut.enqueue(errorToast) + + sut.dismiss(waitToast) + + assertEquals(errorToast, sut.currentToast.value) + sut.dismissCurrentToast() + assertNull(sut.currentToast.value) + } + + @Test + fun `loading toast returns after a toast that interrupts it`() = test { + val sut = ToastQueueManager(backgroundScope) + val infoToast = Toast(type = Toast.ToastType.SUCCESS, title = "Back online", autoHide = true) + sut.enqueue(waitToast) + sut.enqueue(infoToast) + assertEquals(infoToast, sut.currentToast.value) + + advanceTimeBy(Toast.VISIBILITY_TIME_DEFAULT + 1) + + assertEquals(waitToast, sut.currentToast.value) + } + + @Test + fun `loading toast stays behind a second toast that interrupts it`() = test { + val sut = ToastQueueManager(backgroundScope) + val firstToast = Toast(type = Toast.ToastType.SUCCESS, title = "Back online", autoHide = true) + val secondToast = Toast(type = Toast.ToastType.INFO, title = "Payment received", autoHide = true) + sut.enqueue(waitToast) + sut.enqueue(firstToast) + sut.enqueue(secondToast) + assertEquals(secondToast, sut.currentToast.value) + + advanceTimeBy(Toast.VISIBILITY_TIME_DEFAULT + 1) + + assertEquals(waitToast, sut.currentToast.value) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a722178112..0c790d892c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -206,7 +206,6 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PendingChannelMigration -import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet @@ -4704,26 +4703,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() sut.setIsAuthenticated(true) whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { awaitCancellation() } - whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") - whenever(context.getString(R.string.profile__auth_error_timeout)).thenReturn("Authorization timed out") + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(context.getString(R.string.pubky_auth__identity_unavailable)) + .thenReturn("Couldn't Load Your Pubky Profile") val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" val bolt11 = "lnbcrt1posttimeoutscan" stubLightningScan(bolt11 = bolt11, amountSats = 500u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) - advanceTimeBy(PubkyService.AUTHORIZATION_TIMEOUT.inWholeMilliseconds) + advanceTimeBy(AppViewModel.PUBKY_AUTH_DEEPLINK_TIMEOUT.inWholeMilliseconds) runCurrent() assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() - verify(toastManager).enqueue( - check { - assertEquals(Toast.ToastType.ERROR, it.type) - assertEquals("Authorization failed", it.title) - assertEquals("Authorization timed out", it.description) - } - ) + val errorToast = argumentCaptor().run { + verify(toastManager, times(2)).enqueue(capture()) + lastValue + } + assertEquals(Toast.ToastType.ERROR, errorToast.type) + assertEquals("Couldn't Load Your Pubky Profile", errorToast.title) sut.onScanResult(bolt11) advanceUntilIdle() @@ -4944,19 +4943,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = null retry = CompletableDeferred() + clearInvocations(toastManager) sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) runCurrent() assertNull(sut.currentSheet.value) - verify(toastManager, never()).enqueue(any()) + val waitToast = argumentCaptor().run { + verify(toastManager).enqueue(capture()) + firstValue + } + assertTrue(waitToast.isLoading) + assertFalse(waitToast.autoHide) + assertEquals(AppViewModel.PUBKY_AUTH_WAIT_TOAST_TAG, waitToast.testTag) + verify(toastManager, never()).dismiss(any()) retry.complete(Unit) advanceUntilIdle() assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) - verify(toastManager, never()).enqueue(any()) + verify(toastManager).dismiss(waitToast) + verify(toastManager).enqueue(any()) } } + @Test + fun `cold pubky auth deeplink retries a deferred session restore until it succeeds`() = test { + enablePaykitUi() + advanceUntilIdle() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + var attempts = 0 + whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer { + attempts++ + if (attempts < 3) return@doSuspendableAnswer PubkyIdentityReadiness.Unavailable + pubkyPublicKey.value = testPublicKey + PubkyIdentityReadiness.Ready + } + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertEquals(3, attempts) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verify(toastManager).dismiss(check { assertTrue(it.isLoading) }) + } + @Test fun `pubky auth deeplink shows a retryable error for a saved identity it cannot restore`() = test { enablePaykitUi() @@ -4974,16 +5005,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertNull(sut.currentSheet.value) - verify(pubkyRepo).awaitIdentityReady() + verify(pubkyRepo, atLeast(2)).awaitIdentityReady() verify(context, never()).getString(R.string.pubky_auth__no_identity) verify(context, never()).getString(R.string.pubky_auth__already_signed_in) - verify(toastManager).enqueue( - check { - assertEquals(Toast.ToastType.ERROR, it.type) - assertEquals("Couldn't Load Your Pubky Profile", it.title) - assertEquals("Check your connection and try again.", it.description) - } - ) + val (waitToast, errorToast) = argumentCaptor().run { + verify(toastManager, times(2)).enqueue(capture()) + allValues + } + assertTrue(waitToast.isLoading) + verify(toastManager).dismiss(waitToast) + assertEquals(Toast.ToastType.ERROR, errorToast.type) + assertEquals("Couldn't Load Your Pubky Profile", errorToast.title) + assertEquals("Check your connection and try again.", errorToast.description) + assertEquals(Toast.VISIBILITY_TIME_LONG, errorToast.visibilityTime) } } @@ -5013,20 +5047,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { enablePaykitUi() advanceUntilIdle() sut.setIsAuthenticated(true) + whenever(pubkyRepo.hasIdentity()).thenReturn(true) whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer { awaitCancellation() } - whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") - whenever(context.getString(R.string.profile__auth_error_timeout)).thenReturn("Authorization timed out") + whenever(context.getString(R.string.pubky_auth__identity_unavailable_desc)) + .thenReturn("Check your connection and try again.") val bolt11 = "lnbcrt1postretrytimeoutscan" stubLightningScan(bolt11 = bolt11, amountSats = 500u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) - advanceTimeBy(PubkyService.AUTHORIZATION_TIMEOUT.inWholeMilliseconds) + advanceTimeBy(AppViewModel.PUBKY_AUTH_DEEPLINK_TIMEOUT.inWholeMilliseconds) runCurrent() assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() - verify(toastManager).enqueue(check { assertEquals("Authorization timed out", it.description) }) + val enqueued = argumentCaptor().run { + verify(toastManager, times(2)).enqueue(capture()) + allValues + } + verify(toastManager).dismiss(enqueued.first()) + assertEquals("Check your connection and try again.", enqueued.last().description) sut.onScanResult(bolt11) advanceUntilIdle() diff --git a/changelog.d/next/1514.fixed.md b/changelog.d/next/1514.fixed.md new file mode 100644 index 0000000000..cfcb089543 --- /dev/null +++ b/changelog.d/next/1514.fixed.md @@ -0,0 +1 @@ +Fixed Pubky sign-in links closing Bitkit or doing nothing while it starts; Bitkit now shows that it is loading your profile until the request opens. diff --git a/journeys/README.md b/journeys/README.md index 8f9d16b3ae..ef9fd7367f 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -172,6 +172,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `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-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 f9c30e1a5c..0917a92dfb 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,5 +1,15 @@ # Pubky authorization +## Sign-in from a link + +- `signin-cold-start.xml` checks a `pubkyauth://signin_grant` link opened while Bitkit is not running: + the loading toast while the saved session restores, then the authorization sheet. The iOS + counterpart is `open-signin-link.xml`, which opens the same flow through `bitkit://pubky-auth/signin`; + only Android has the raw `pubkyauth` scheme, the loading toast and Authorize through to + `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. + ## Grant signup - `grant-signup.xml` checks consent, cancellation, homeserver registration, and profile setup for a fresh identity. diff --git a/journeys/pubky-auth/signin-cold-start.xml b/journeys/pubky-auth/signin-cold-start.xml new file mode 100644 index 0000000000..2c038ee736 --- /dev/null +++ b/journeys/pubky-auth/signin-cold-start.xml @@ -0,0 +1,21 @@ + + + Verifies that a sign-in link opened while Bitkit is not running waits visibly for the saved Pubky + session and then opens the authorization sheet. Precondition: an unlocked wallet with Paykit + enabled and a locally managed Pubky identity, and a requesting app that supplies a fresh + pubkyauth://signin_grant URL with a reachable homeserver and relay. The iOS counterpart is + pubky-auth/open-signin-link.xml (bitkit://pubky-auth/signin); only Android has the raw pubkyauth + scheme, the loading toast and Authorize through to PubkyAuthOK. + + + Run adb shell am force-stop to.bitkit.dev + Run adb shell am start -a android.intent.action.VIEW -d "<the prepared signin_grant URL>" + Unlock Bitkit with PIN or biometrics when prompted + Verify a toast with a spinner reads Loading Your Pubky Profile (screenshot; toasts are not in the layout) + Verify the toast stays until the authorization sheet opens + Verify the authorization sheet shows the requesting app and permissions + Tap Authorize (id "PubkyAuthAuthorize") and complete PIN or biometric approval when enabled + Verify the authorization success state is shown (id "PubkyAuthOK") + Verify the requesting app receives approval from the recorded public key + +