diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index bb5a2360ca..6204c8db7f 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1532,6 +1532,18 @@ class PubkyRepo @Inject constructor( keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name) && !keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name) } + /** + * The identity of the key Bitkit stores for the saved session, read without restoring the session. + * Null for a Ring-sourced identity, or without both a saved session and a stored key. + */ + suspend fun localIdentityPublicKey(): String? = runSuspendCatching { + withContext(ioDispatcher) { + if (!hasSavedSession() || !hasLocalSecretKey()) return@withContext null + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext null + pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() + } + }.getOrNull() + 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/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index cca86961c0..c62a0ec357 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -535,7 +535,7 @@ class PaykitSdkService @Inject constructor( approvedClientId: String, secretKeyHex: String, ) { - isSetup.await() + ensurePlatformSetup() return operationLock.withLock { approvalBootstrap(authUrl, approvedClientId).approveAuth( authUrl = authUrl, @@ -552,10 +552,10 @@ class PaykitSdkService @Inject constructor( secretKeyHex: String, claim: PubkyAuthCompanionClaim, ) { - isSetup.await() + val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow() + ?: throw PubkyAuthRequestError.MissingBitkitClaim + if (requestedClaim.includesPaykitAccess) isSetup.await() else ensurePlatformSetup() return operationLock.withLock { - val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow() - ?: throw PubkyAuthRequestError.MissingBitkitClaim require( claim.queryParameter == PubkyAuthClaim.QUERY_PARAMETER && claim.claimType == requestedClaim.wireValue ) { @@ -1557,6 +1557,10 @@ class PaykitSdkService @Inject constructor( private fun bootstrap() = cachedBootstrap + private fun ensurePlatformSetup() { + if (!isSetup.isCompleted || setupFailed) platformInitializer() + } + private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap { val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty() return PubkySessionBootstrap.withPubkyClientConfig( diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 8dd9bc71ad..e02ad06ebd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -113,6 +113,8 @@ class PubkyAuthApprovalViewModel @Inject constructor( it.isNotEmpty() }?.joinToString(context.getString(R.string.profile__auth_approval_services_separator)) ?: unknownService val usdtEnabled = usdtPreferences.enabled.first() + val identityPublicKey = pubkyRepo.publicKey.value ?: pubkyRepo.localIdentityPublicKey() + val profile = approvalProfile(identityPublicKey) _uiState.update { it.copy( state = if (request.bitkitClaim?.sharesBitcoin == true || @@ -124,11 +126,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( }, clientId = request.clientId, homeserverPublicKey = request.homeserverPublicKey, - createsIdentity = request.requiresIdentityCreation(pubkyRepo.publicKey.value != null), + createsIdentity = request.requiresIdentityCreation(identityPublicKey != null), serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, - profile = approvalProfile(), + profile = profile, isUsdtEnabled = usdtEnabled, shareUsdt = usdtEnabled, ) @@ -416,8 +418,10 @@ class PubkyAuthApprovalViewModel @Inject constructor( return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) } - private fun approvalProfile() = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> - PubkyProfile.forDisplay( + private fun approvalProfile(identityPublicKey: String?): PubkyProfile? { + pubkyRepo.profile.value?.let { return it } + val publicKey = identityPublicKey ?: return null + return PubkyProfile.forDisplay( publicKey = publicKey, name = pubkyRepo.displayName.value, imageUrl = pubkyRepo.displayImageUri.value, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b541e2a233..8f9196453d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3149,6 +3149,10 @@ class AppViewModel @Inject constructor( private suspend fun awaitPubkyAuthIdentity() { while (pubkyRepo.awaitIdentityReady() == PubkyIdentityReadiness.Unavailable) { + if (isOnline.value == ConnectivityState.CONNECTED && pubkyRepo.localIdentityPublicKey() != null) { + Logger.debug("Continuing sign-in link with the stored Pubky key", context = TAG) + return + } delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL) isOnline.first { it == ConnectivityState.CONNECTED } Logger.debug("Retrying Pubky session restore for a sign-in link", context = TAG) @@ -7659,7 +7663,8 @@ class AppViewModel @Inject constructor( } if (pubkyRepo.publicKey.value == null) { - showPubkyIdentityUnavailableToast() + if (pubkyRepo.localIdentityPublicKey() == null) return showPubkyIdentityUnavailableToast() + showSheet(Sheet.PubkyAuth(authUrl)) return } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 23aadaf43f..6cd2311f82 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1785,6 +1785,20 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.hasLocalSecretKey()) } + @Test + fun `localIdentityPublicKey needs a saved session and a stored key without a Ring source`() = test { + whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(true) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret") + whenever(pubkyService.publicKeyFromSecret("local_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + assertNull(sut.localIdentityPublicKey()) + + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + assertEquals(VALID_SELF_KEY, sut.localIdentityPublicKey()) + + whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true) + assertNull(sut.localIdentityPublicKey()) + } + @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/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 551190f1ac..b03e880217 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -1390,6 +1391,55 @@ class PaykitSdkServiceTest { } } + @Test + fun `approval waits only for platform setup unless its claim grants Paykit access`() = runTest { + var platformSetups = 0 + val service = PaykitSdkService( + mock(), + mock(), + mock(), + platformInitializer = { platformSetups++ }, + settingsStore = mock(), + ) { mock() } + val paykitAccess = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1) + val watchOnly = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) + fun companion(claim: PubkyAuthClaim) = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = claim.wireValue, + unsignedPayload = ByteArray(84).apply { this[0] = 1 }, + ) + + val approvals = listOf( + async { runSuspendCatching { service.approveAuth("pubkyauth://signin", "/pub/a/:rw", "test", "00") } }, + async { + runSuspendCatching { + service.approveAuthWithCompanionClaim( + "pubkyauth://signin?x-bitkit-claim=${watchOnly.wireValue}", + PubkyAuthClaim.REQUIRED_CAPABILITIES, + "test", + "00", + companion(watchOnly), + ) + } + }, + ) + val paykitAccessApproval = async { + service.approveAuthWithCompanionClaim( + "pubkyauth://signin?x-bitkit-claim=${paykitAccess.wireValue}", + PubkyAuthClaim.REQUIRED_CAPABILITIES, + "test", + "00", + companion(paykitAccess), + ) + } + advanceUntilIdle() + + assertTrue(approvals.all { it.isCompleted }) + assertEquals(2, platformSetups) + assertFalse(paykitAccessApproval.isCompleted) + paykitAccessApproval.cancel() + } + @Test fun `registered identity activation persists credentials or clears partial activation`() = runTest { for (failure in listOf(null, "session", "secret", "initialize", "authorize", "cancel")) { diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index 20dfc4741c..772555bef6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -421,6 +421,33 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } } + @Test + fun `grant signup with only a stored key shows that identity and authorizes without identity creation`() = test { + val authUrl = "pubkyauth://signup_grant?hs=homeserver" + val request = PubkyAuthRequest.parseGrantSignup( + rawUrl = authUrl, + clientId = clientId, + relay = "https://relay.example", + capabilities = "/pub/example/:rw", + homeserverPublicKey = "homeserver", + ).getOrThrow() + whenever(pubkyRepo.localIdentityPublicKey()).thenReturn("pubky_stored") + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveAuth(authUrl, request.capabilities, clientId)).thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + assertFalse(sut.uiState.value.createsIdentity) + assertEquals("pubky_stored", sut.uiState.value.profile?.publicKey) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuth(authUrl, request.capabilities, clientId) } + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } + } + @Test fun `terminal signup outcomes allow the same URL to reload for consent`() = test { listOf(Result.success(Unit), Result.failure(PubkyAlreadySignedInError)).forEach { outcome -> diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c1978c8339..13513a82dd 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -5015,6 +5015,55 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager).dismiss(check { assertTrue(it.isLoading) }) } + @Test + fun `cold pubky auth deeplink opens the sheet with the stored key while the restore stays deferred`() = test { + enablePaykitUi() + advanceUntilIdle() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.awaitIdentityReady()).thenReturn(PubkyIdentityReadiness.Unavailable) + whenever(pubkyRepo.localIdentityPublicKey()).thenReturn(testPublicKey) + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertNull(pubkyPublicKey.value) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verify(pubkyRepo, times(1)).awaitIdentityReady() + verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager).dismiss(check { assertTrue(it.isLoading) }) + } + + @Test + fun `offline cold pubky auth deeplink keeps waiting for the restore despite a stored key`() = test { + enablePaykitUi() + advanceUntilIdle() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + whenever(pubkyRepo.localIdentityPublicKey()).thenReturn(testPublicKey) + var attempts = 0 + whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer { + attempts++ + if (attempts < 2) return@doSuspendableAnswer PubkyIdentityReadiness.Unavailable + pubkyPublicKey.value = testPublicKey + PubkyIdentityReadiness.Ready + } + connectivityState.value = ConnectivityState.DISCONNECTED + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceTimeBy(10_000) + runCurrent() + + assertEquals(1, attempts) + assertNull(sut.currentSheet.value) + connectivityState.value = ConnectivityState.CONNECTED + advanceUntilIdle() + + assertEquals(2, attempts) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } + @Test fun `pubky auth deeplink shows a retryable error for a saved identity it cannot restore`() = test { enablePaykitUi() diff --git a/changelog.d/next/1541.fixed.md b/changelog.d/next/1541.fixed.md new file mode 100644 index 0000000000..6776f019a8 --- /dev/null +++ b/changelog.d/next/1541.fixed.md @@ -0,0 +1 @@ +Pubky sign-in links that open Bitkit now show the Authorize sheet even while your Pubky profile is still loading. diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 0917a92dfb..61005c1e73 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -3,7 +3,8 @@ ## 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 + the loading toast while the saved session restores, then the authorization sheet, which opens with + the stored key's identity when the restore is deferred while online. 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 diff --git a/journeys/pubky-auth/signin-cold-start.xml b/journeys/pubky-auth/signin-cold-start.xml index 2c038ee736..1cb4cfff0b 100644 --- a/journeys/pubky-auth/signin-cold-start.xml +++ b/journeys/pubky-auth/signin-cold-start.xml @@ -1,7 +1,8 @@ - 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 + Verifies that a sign-in link opened while Bitkit is not running shows a loading toast while the + saved Pubky session restores, then opens the authorization sheet; when the restore is deferred + while the device is online, the sheet opens with the stored key's identity. 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