diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd89e25350..12045520f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -165,7 +165,7 @@ android:resource="@xml/shortcuts" /> - + (null) @@ -124,6 +126,9 @@ class PubkyRepo @Inject constructor( private val _adoptedSourceLost = MutableStateFlow(false) val adoptedSourceLost: StateFlow = _adoptedSourceLost.asStateFlow() + private val _adoptedSourceUnreachable = MutableStateFlow(false) + val adoptedSourceUnreachable: StateFlow = _adoptedSourceUnreachable.asStateFlow() + private val _pendingImportProfile = MutableStateFlow(null) val pendingImportProfile: StateFlow = _pendingImportProfile.asStateFlow() @@ -183,7 +188,7 @@ class PubkyRepo @Inject constructor( suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { awaitInitialization() val restored = initializeMutex.withLock { - if (_publicKey.value != null) return@withLock false + if (_publicKey.value != null || hasWaitingRingPick()) return@withLock false runSuspendCatching { val hasIdentity = hasIdentity() _identityRefreshVersion.update { it + 1 } @@ -220,15 +225,24 @@ class PubkyRepo @Inject constructor( Logger.error("Failed to initialize paykit", it, context = TAG) }.getOrElse { InitResult.RestorationFailed } - when (result) { + val restored = when (result) { is InitResult.NoSession -> { clearAuthenticatedState() Logger.debug("Found no saved paykit session", context = TAG) + false + } + is InitResult.Restored if !notifyFailure && hasWaitingRingPick() -> { + Logger.info( + "Deferred restored paykit session for '${redacted(result.publicKey)}' to a ring pick", + context = TAG, + ) + false } is InitResult.Restored -> { _sessionRestorationFailed.update { false } _publicKey.update { result.publicKey } Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) + true } is InitResult.RestorationFailed -> { clearAuthenticatedState( @@ -236,12 +250,15 @@ class PubkyRepo @Inject constructor( clearRestorationFailure = notifyFailure, ) if (notifyFailure) _sessionRestorationFailed.update { true } + false } } initializationReady.complete(Unit) - return result is InitResult.Restored + return restored } + private fun hasWaitingRingPick(): Boolean = waitingRingPicks.get() > 0 + private fun hasSavedSession(): Boolean = runCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }.getOrNull()?.isNotBlank() == true @@ -320,22 +337,38 @@ class PubkyRepo @Inject constructor( private suspend fun checkAdoptedSourcePresent() { if (!adoptedSourceCheckMutex.tryLock()) return try { - val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return + val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: run { + _adoptedSourceUnreachable.update { false } + return + } val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { Logger.warn("Failed to list ring identities", it, context = TAG) + initializeMutex.withLock { + if (keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) != reference) return@withLock + _adoptedSourceUnreachable.update { true } + } + return + } + if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) { + _adoptedSourceUnreachable.update { false } return } - if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return initializeMutex.withLock { val currentReference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) if (currentReference != reference) return@withLock - Logger.warn("Adopted ring identity '${redacted(reference)}' is gone, clearing session", context = TAG) - runSuspendCatching { pubkyService.clearSessionAccess() } - .onFailure { Logger.warn("Failed to clear adopted session access", it, context = TAG) } - clearLocalState() - _adoptedSourceLost.update { true } + val adoptedPubky = reference.removePrefix(SharedPubkyContract.RING_SOURCE_PREFIX) + Logger.warn( + "Adopted ring identity '${redacted(adoptedPubky)}' is gone, clearing session", + context = TAG, + ) + withContext(NonCancellable) { + runSuspendCatching { pubkyService.clearSessionAccess() } + .onFailure { Logger.warn("Failed to clear adopted session access", it, context = TAG) } + clearLocalState() + _adoptedSourceLost.update { true } + } } } finally { adoptedSourceCheckMutex.unlock() @@ -343,81 +376,130 @@ class PubkyRepo @Inject constructor( } suspend fun adoptRingIdentity(pubky: String): Result = withContext(ioDispatcher) { - val reference = "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky" - var identityInstalled = false - try { - runSuspendCatching { - val publicKey = initializeMutex.withLock { - ensureServiceInitialized() - val secretKeyHex = sharedPubkyClient.ringCredential(pubky).getOrThrow() - val rawPublicKey = pubkyService.publicKeyFromSecret(secretKeyHex) - require(PubkyPublicKeyFormat.matches(rawPublicKey, pubky)) { - "Ring credential does not match '${redacted(pubky)}'" - } - keychain.upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, reference) - signInOrSignUpAdoptedIdentity(secretKeyHex, rawPublicKey) - - val prefixedPublicKey = rawPublicKey.ensurePubkyPrefix() - clearProfileIfIdentityChanged(prefixedPublicKey) - _publicKey.update { prefixedPublicKey } - identityInstalled = true - notifyBackupStateChanged() - Logger.info("Adopted ring identity for '${redacted(rawPublicKey)}'", context = TAG) - prefixedPublicKey - } - - loadProfile() - loadContacts() - - initializeMutex.withLock { - check(_publicKey.value == publicKey) { "Adopted Pubky identity changed before setup completed" } - val hasProfile = _profile.value?.publicKey == publicKey - runSuspendCatching { settingsStore.setPubkyProfileSetupPending(!hasProfile) } - .onFailure { Logger.warn("Failed to save pending profile setup", it, context = TAG) } - hasProfile + runSuspendCatching { + val hasProfile = withRingPickLock { + if (_publicKey.value != null) throw PubkyAlreadySignedInError + ensureServiceInitialized() + val secretKeyHex = sharedPubkyClient.ringCredential(pubky).getOrThrow() + val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex) + require(PubkyPublicKeyFormat.matches(publicKey, pubky)) { + "Ring credential does not match '${redacted(pubky)}'" } - }.onFailure { clearAdoptedSourceIfMatches(reference) } - } catch (error: CancellationException) { - if (!identityInstalled) clearAdoptedSourceIfMatches(reference) - throw error + withContext(NonCancellable) { commitRingIdentity(pubky, publicKey, secretKeyHex) } + } + withContext(NonCancellable) { loadContacts() } + hasProfile + }.onFailure { + if (it !is PubkyAlreadySignedInError) Logger.error("Failed to adopt ring identity", it, context = TAG) } } - private suspend fun clearAdoptedSourceIfMatches(reference: String) { - runSuspendCatching { - if (keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) == reference) { - keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) - } - }.onFailure { Logger.warn("Failed to clear adopted ring source", it, context = TAG) } + private suspend fun withRingPickLock(block: suspend () -> T): T { + waitingRingPicks.incrementAndGet() + try { + initializeMutex.lock() + } finally { + waitingRingPicks.decrementAndGet() + } + return try { + block() + } finally { + initializeMutex.unlock() + } } - private suspend fun signInOrSignUpAdoptedIdentity(secretKeyHex: String, publicKey: String) { + private suspend fun commitRingIdentity(pubky: String, publicKey: String, secretKeyHex: String): Boolean { + val previousReference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) val previousSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - var completed = false - try { - runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse { - val hasIdentityRecord = runSuspendCatching { pubkyService.hasIdentityRecord(publicKey) } - .onFailure { Logger.warn("Failed to check ring identity record", it, context = TAG) } - .getOrNull() - if (hasIdentityRecord != false) throw it - Logger.warn("Signing up ring identity without a published record", it, context = TAG) - val homegate = fetchHomegateSignupCode() - pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) - } - completed = true + var signInAttempted = false + var committed = false + val profile = try { + keychain.upsertString( + Keychain.Key.SHARED_PUBKY_SOURCE.name, + "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky", + ) + signInAttempted = true + signInRingIdentity(publicKey, secretKeyHex, previousSession) + val remoteProfile = fetchRemoteProfile(publicKey) + .onFailure { Logger.warn("Failed to look up adopted ring profile", it, context = TAG) } + .getOrNull() + settingsStore.setPubkyProfileSetupPending(remoteProfile == null) + committed = true + remoteProfile } finally { - if (!completed) { - withContext(NonCancellable + ioDispatcher) { - val installedSession = runSuspendCatching { - val currentSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - currentSession != null && currentSession != previousSession - }.onFailure { - Logger.warn("Failed to identify incomplete adopted Pubky session", it, context = TAG) - }.getOrDefault(false) - if (installedSession) discardAbandonedSession() - } - } + if (!committed) rollBackRingIdentity(previousReference, previousSession, signInAttempted) + } + + val prefixedPublicKey = publicKey.ensurePubkyPrefix() + clearProfileIfIdentityChanged(prefixedPublicKey) + profile?.let { adoptedProfile -> + _profile.update { adoptedProfile } + runSuspendCatching { cacheMetadata(adoptedProfile) } + .onFailure { Logger.warn("Failed to cache adopted ring profile", it, context = TAG) } + } + _adoptedSourceUnreachable.update { false } + _publicKey.update { prefixedPublicKey } + notifyBackupStateChanged() + Logger.info("Adopted ring identity for '${redacted(publicKey)}'", context = TAG) + return profile != null + } + + private suspend fun signInRingIdentity( + publicKey: String, + secretKeyHex: String, + previousSession: String?, + ) { + runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse { + if (hasSessionChangedSince(previousSession)) throw it + val hasIdentityRecord = runSuspendCatching { pubkyService.hasIdentityRecord(publicKey) } + .onFailure { Logger.warn("Failed to check ring identity record", it, context = TAG) } + .getOrNull() + if (hasIdentityRecord != false) throw it + Logger.warn("Signing up ring identity without a published record", it, context = TAG) + val homegate = fetchHomegateSignupCode() + pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) + } + } + + private fun hasSessionChangedSince(previousSession: String?): Boolean { + val currentSession = runCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) } + return currentSession.isFailure || currentSession.getOrNull() != previousSession + } + + private suspend fun rollBackRingIdentity( + previousReference: String?, + previousSession: String?, + signInAttempted: Boolean, + ) { + if (signInAttempted && hasSessionChangedSince(previousSession)) { + Logger.info("Discarding session saved by a failed ring identity adoption", context = TAG) + discardRingSession() + restoreRingReference(null) + return } + restoreRingReference(previousReference?.takeIf { !previousSession.isNullOrEmpty() }) + } + + private suspend fun discardRingSession() { + val revocationError = runSuspendCatching { pubkyService.signOut() }.exceptionOrNull() ?: return + Logger.warn("Failed to revoke abandoned ring session", revocationError, context = TAG) + val forgetError = runSuspendCatching { pubkyService.forgetSessionAccess() }.exceptionOrNull() ?: return + Logger.warn("Failed to forget abandoned ring session access", forgetError, context = TAG) + runSuspendCatching { pubkyService.clearSessionAccess() } + .onFailure { Logger.warn("Failed to clear abandoned ring session access", it, context = TAG) } + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + .onFailure { Logger.warn("Failed to delete abandoned ring session", it, context = TAG) } + } + + private suspend fun restoreRingReference(reference: String?) { + runSuspendCatching { + if (reference == null) { + keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + } else { + keychain.upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, reference) + } + }.onFailure { Logger.warn("Failed to restore ring identity reference", it, context = TAG) } + notifyBackupStateChanged() } private suspend fun clearProfileIfIdentityChanged(publicKey: String) { @@ -1412,6 +1494,7 @@ class PubkyRepo @Inject constructor( _contactsLoadCompletionVersion.update { 0L } clearPendingImport() if (clearRestorationFailure) _sessionRestorationFailed.update { false } + if (clearCachedProfile) _adoptedSourceUnreachable.update { false } } private fun markContactsLoaded() { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c2a5fde8fa..1d20bfc0e2 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1250,8 +1250,8 @@ internal class PaykitSdkSessionProvider( override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { - delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) clearPubkySessionCredentials(::delete) + delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) } } diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index ba19d236f6..1a05fce112 100644 --- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -21,7 +21,13 @@ 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. + * + * Both handlers are re-checked when the identity, the Paykit flag, the saved identity state or the reachability of + * an adopted Pubky Ring pubky changes, so the authorization handler follows Pubky Ring going away and coming back + * without a restart. + */ @Singleton internal class PubkyAuthHandlerRegistrar @Inject constructor( @ApplicationContext private val context: Context, @@ -54,7 +60,8 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( pubkyRepo.publicKey, pubkyRepo.backupStateVersion, pubkyRepo.identityRefreshVersion, - ) { localFlagEnabled, publicKey, _, _ -> + pubkyRepo.adoptedSourceUnreachable, + ) { localFlagEnabled, publicKey, _, _, _ -> val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() } .onFailure { Logger.warn("Failed to read saved Pubky identity", it, context = TAG) } .getOrDefault(true) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 17eef049ff..11ecd0f66e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -49,8 +50,9 @@ class PubkyChoiceViewModel @Inject constructor( } fun onIdentityClick(pubky: String) { + if (_uiState.value.adoptingPubky != null) return + _uiState.update { it.copy(adoptingPubky = pubky) } viewModelScope.launch { - _uiState.update { it.copy(adoptingPubky = pubky) } pubkyRepo.adoptRingIdentity(pubky) .onSuccess { hasProfile -> if (hasProfile) { @@ -72,7 +74,10 @@ class PubkyChoiceViewModel @Inject constructor( _effects.emit(effect) } .onFailure { - Logger.error("Failed to adopt ring identity", it, context = TAG) + if (it is PubkyAlreadySignedInError) { + _uiState.update { state -> state.copy(adoptingPubky = null, navigateToProfile = true) } + return@onFailure + } _uiState.update { state -> state.copy(adoptingPubky = null) } ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7e5d5e9810..05e8ded778 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -24,17 +24,24 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations +import org.mockito.invocation.InvocationOnMock import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doSuspendableAnswer @@ -45,6 +52,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData @@ -62,12 +70,16 @@ import to.bitkit.services.PubkyRingAuthTimeoutError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CopyOnWriteArraySet import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @Suppress("LargeClass") @@ -78,6 +90,12 @@ class PubkyRepoTest : BaseUnitTest() { private const val NON_CANONICAL_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + + /** Ring source reference that an adoption of [VALID_SELF_KEY] saves. */ + private val RING_REFERENCE = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_SELF_KEY.removePrefix("pubky") + + /** Real-time bound on waiting for a stubbed step that runs on a service queue thread. */ + private val STEP_TIMEOUT = 5.seconds } private lateinit var sut: PubkyRepo @@ -90,6 +108,9 @@ class PubkyRepoTest : BaseUnitTest() { private val settingsStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) private val profileSetupPending = MutableStateFlow(false) + private val ringKeychain = ConcurrentHashMap() + private val ringPickEvents = CopyOnWriteArrayList() + private val failingRingTeardowns = CopyOnWriteArraySet() private var adoptedSource: String? = null @Before @@ -1124,12 +1145,443 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(profileSetupPending.value) } + @Test + fun `adoptRingIdentity should finish the pick when cancelled during sign in`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + val signInStarted = CompletableDeferred() + val finishSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + ServiceQueue.CORE.background { + signInStarted.complete(Unit) + finishSignIn.await() + saveRingSession() + } + } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val signInStartedInTime = signInStarted.awaitStarted() + pick.cancel() + finishSignIn.complete(Unit) + pick.join() + + assertTrue(signInStartedInTime, "Timed out waiting for sign in to start") + assertTrue(pick.isCancelled) + assertRingPickCommitted() + } + + @Test + fun `adoptRingIdentity should finish a pick with a profile when cancelled during sign in`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + val signInStarted = CompletableDeferred() + val finishSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + ServiceQueue.CORE.background { + signInStarted.complete(Unit) + finishSignIn.await() + saveRingSession() + } + } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Alice"))) + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val signInStartedInTime = signInStarted.awaitStarted() + pick.cancel() + finishSignIn.complete(Unit) + pick.join() + + assertTrue(signInStartedInTime, "Timed out waiting for sign in to start") + assertTrue(pick.isCancelled) + assertRingPickCommitted(hasProfile = true) + assertEquals("Alice", sut.profile.value?.name) + } + + @Test + fun `adoptRingIdentity should finish the pick when cancelled during the profile lookup`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingSignInSavesSession() + val lookupStarted = CompletableDeferred() + val finishLookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + ServiceQueue.CORE.background { + lookupStarted.complete(Unit) + finishLookup.await() + null + } + } + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val lookupStartedInTime = lookupStarted.awaitStarted() + pick.cancel() + finishLookup.complete(Unit) + pick.join() + + assertTrue(lookupStartedInTime, "Timed out waiting for the profile lookup to start") + assertTrue(pick.isCancelled) + assertRingPickCommitted() + } + + @Test + fun `adoptRingIdentity should finish the contact load when cancelled during it`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingSignInSavesSession() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + val contactsStarted = CompletableDeferred() + val finishContacts = CompletableDeferred() + whenever(pubkyService.contactRecords()).doSuspendableAnswer { + ServiceQueue.CORE.background { + contactsStarted.complete(Unit) + finishContacts.await() + emptyList() + } + } + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val contactsStartedInTime = contactsStarted.awaitStarted() + pick.cancel() + finishContacts.complete(Unit) + pick.join() + + assertTrue(contactsStartedInTime, "Timed out waiting for the contact load to start") + assertTrue(pick.isCancelled) + assertRingPickCommitted() + } + + @Test + fun `adoptRingIdentity should write nothing when cancelled before the commit`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + val derivationStarted = CompletableDeferred() + val finishDerivation = CompletableDeferred() + whenever(pubkyService.publicKeyFromSecret("ring_secret")).doSuspendableAnswer { + ServiceQueue.CORE.background { + derivationStarted.complete(Unit) + finishDerivation.await() + ringPubky + } + } + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val derivationStartedInTime = derivationStarted.awaitStarted() + pick.cancel() + finishDerivation.complete(Unit) + pick.join() + + assertTrue(derivationStartedInTime, "Timed out waiting for key derivation to start") + assertTrue(pick.isCancelled) + assertTrue(ringPickEvents.isEmpty()) + assertTrue(ringKeychain.isEmpty()) + verifyBlocking(pubkyService, never()) { signIn(any()) } + } + + @Test + fun `adoptRingIdentity should reject a retried pick once an abandoned pick signs in`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + val firstStarted = CompletableDeferred() + val finishFirst = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + if (!firstStarted.isCompleted) { + withContext(NonCancellable) { + firstStarted.complete(Unit) + finishFirst.await() + } + } + saveRingSession() + currentCoroutineContext().ensureActive() + } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val abandoned = async { sut.adoptRingIdentity(ringPubky) } + val firstSignInStarted = firstStarted.isCompleted + abandoned.cancel() + val retried = async { sut.adoptRingIdentity(ringPubky) } + val eventsWhileFirstSignsIn = ringPickEvents.toList() + finishFirst.complete(Unit) + abandoned.join() + + assertTrue(firstSignInStarted) + assertEquals(listOf("save reference"), eventsWhileFirstSignsIn) + assertTrue(abandoned.isCancelled) + assertEquals(PubkyAlreadySignedInError, retried.await().exceptionOrNull()) + assertRingPickCommitted() + verifyBlocking(pubkyService, times(1)) { signIn("ring_secret") } + } + + @Test + fun `adoptRingIdentity should refuse to run over a signed-in identity`() = test { + authenticateForTesting() + val ringPubky = stubRingCredential() + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals(PubkyAlreadySignedInError, result.exceptionOrNull()) + assertEquals("pubkytest_pk_12345", sut.publicKey.value) + verifyBlocking(sharedPubkyClient, never()) { ringCredential(any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(keychain, never()) { upsertString(eq(Keychain.Key.SHARED_PUBKY_SOURCE.name), any()) } + } + + @Test + fun `adoptRingIdentity should discard the saved session before the reference when activation fails`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingActivationFailure() + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Initialize failed", result.exceptionOrNull()?.message) + assertEquals(listOf("save reference", "save session", "sign out", "delete reference"), ringPickEvents) + assertRingPickSignedOut() + verifyBlocking(pubkyService, never()) { hasIdentityRecord(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + } + + @Test + fun `adoptRingIdentity should not sign up after sign in saved a session`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingActivationFailure() + whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(false) + whenever(pubkyService.signUp("ring_secret", "test-homeserver", "test-code")).thenReturn(Unit) + + val result = sut.adoptRingIdentity(ringPubky) + httpClient.close() + + assertEquals("Initialize failed", result.exceptionOrNull()?.message) + assertRingPickSignedOut() + verifyBlocking(pubkyService, never()) { hasIdentityRecord(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + } + + @Test + fun `adoptRingIdentity should abort the pick when the saved session cannot be read`() = test { + val keptReference = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_CONTACT_KEY_A.removePrefix("pubky") + stubRingPickKeychain( + Keychain.Key.PAYKIT_SESSION to "kept_session", + Keychain.Key.SHARED_PUBKY_SOURCE to keptReference, + ) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) + .thenAnswer { throw TestAppError("Locked") } + .thenAnswer { ringKeychain[Keychain.Key.PAYKIT_SESSION.name] } + val ringPubky = stubRingCredential() + stubRingSignInSavesSession() + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Locked", result.exceptionOrNull()?.message) + assertRingPickAborted(keptReference) + } + + @Test + fun `adoptRingIdentity should abort the pick when the saved reference cannot be read`() = test { + val keptReference = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_CONTACT_KEY_A.removePrefix("pubky") + stubRingPickKeychain( + Keychain.Key.PAYKIT_SESSION to "kept_session", + Keychain.Key.SHARED_PUBKY_SOURCE to keptReference, + ) + whenever(keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name)) + .thenAnswer { throw TestAppError("Locked") } + .thenAnswer { ringKeychain[Keychain.Key.SHARED_PUBKY_SOURCE.name] } + val ringPubky = stubRingCredential() + stubRingSignInSavesSession() + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Locked", result.exceptionOrNull()?.message) + assertRingPickAborted(keptReference) + } + + @Test + fun `adoptRingIdentity should roll back the pick when pending profile setup cannot be saved`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingSignInSavesSession() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + whenever(settingsStore.setPubkyProfileSetupPending(true)).thenAnswer { throw TestAppError("Disk full") } + profileSetupPending.value = false + val publicKeys = CopyOnWriteArrayList() + backgroundScope.launch { sut.publicKey.collect { publicKeys += it } } + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Disk full", result.exceptionOrNull()?.message) + assertEquals(listOf("save reference", "save session", "sign out", "delete reference"), ringPickEvents) + assertRingPickSignedOut() + assertEquals(listOf(null), publicKeys) + } + + @Test + fun `adoptRingIdentity should save pending profile setup before publishing the identity`() = test { + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + val publicKeysWhenPending = CopyOnWriteArrayList() + whenever(settingsStore.setPubkyProfileSetupPending(true)).thenAnswer { + publicKeysWhenPending += sut.publicKey.value + profileSetupPending.value = true + Unit + } + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals(false, result.getOrNull()) + assertEquals(listOf(null), publicKeysWhenPending) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `adoptRingIdentity should forget the saved session when revocation fails`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingActivationFailure() + failingRingTeardowns += "sign out" + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Initialize failed", result.exceptionOrNull()?.message) + assertEquals( + listOf("save reference", "save session", "sign out", "forget session", "delete reference"), + ringPickEvents, + ) + assertRingPickSignedOut() + verifyBlocking(pubkyService, never()) { clearSessionAccess() } + } + + @Test + fun `adoptRingIdentity should clear the saved session locally when the sdk cannot`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + stubRingActivationFailure() + failingRingTeardowns += listOf("sign out", "forget session", "clear session access") + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Initialize failed", result.exceptionOrNull()?.message) + assertEquals( + listOf( + "save reference", + "save session", + "sign out", + "forget session", + "clear session access", + "delete session", + "delete reference", + ), + ringPickEvents, + ) + assertRingPickSignedOut() + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + } + + @Test + fun `adoptRingIdentity should roll back a failed pick after it was cancelled`() = test { + stubRingPickKeychain() + val ringPubky = stubRingCredential() + val signInStarted = CompletableDeferred() + val finishSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + ServiceQueue.CORE.background { + signInStarted.complete(Unit) + finishSignIn.await() + saveRingSession() + throw TestAppError("Initialize failed") + } + } + failingRingTeardowns += "sign out" + + val pick = async { sut.adoptRingIdentity(ringPubky) } + val signInStartedInTime = signInStarted.awaitStarted() + pick.cancel() + finishSignIn.complete(Unit) + pick.join() + + assertTrue(signInStartedInTime, "Timed out waiting for sign in to start") + assertTrue(pick.isCancelled) + assertEquals( + listOf("save reference", "save session", "sign out", "forget session", "delete reference"), + ringPickEvents, + ) + assertRingPickSignedOut() + } + + @Test + fun `adoptRingIdentity should keep a saved session and its reference when sign in fails before saving`() = + test { + val keptPubky = VALID_CONTACT_KEY_A.removePrefix("pubky") + val keptReference = SharedPubkyContract.RING_SOURCE_PREFIX + keptPubky + stubRingPickKeychain( + Keychain.Key.PAYKIT_SESSION to "kept_session", + Keychain.Key.SHARED_PUBKY_SOURCE to keptReference, + ) + whenever(pubkyService.importSession("kept_session")).thenAnswer { throw TestAppError("Expired") } + whenever(sharedPubkyClient.ringCredential(keptPubky)) + .thenReturn(Result.failure(TestAppError("Unavailable"))) + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable"))) + sut.initialize() + assertTrue(sut.sessionRestorationFailed.value) + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenAnswer { throw TestAppError("Relay unavailable") } + whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(true) + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Relay unavailable", result.exceptionOrNull()?.message) + assertEquals("kept_session", ringKeychain[Keychain.Key.PAYKIT_SESSION.name]) + assertEquals(keptReference, ringKeychain[Keychain.Key.SHARED_PUBKY_SOURCE.name]) + assertEquals(listOf("save reference", "save reference"), ringPickEvents) + assertNull(sut.publicKey.value) + } + + @Test + fun `adoptRingIdentity should delete a leftover reference without a saved session when sign in fails`() = test { + val leftoverReference = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_CONTACT_KEY_A.removePrefix("pubky") + stubRingPickKeychain(Keychain.Key.SHARED_PUBKY_SOURCE to leftoverReference) + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenAnswer { throw TestAppError("Relay unavailable") } + whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(true) + + val result = sut.adoptRingIdentity(ringPubky) + + assertEquals("Relay unavailable", result.exceptionOrNull()?.message) + assertEquals(listOf("save reference", "delete reference"), ringPickEvents) + assertRingPickSignedOut() + } + + @Test + fun `adoptRingIdentity should mark pubky ring reachable when a pick commits`() = test { + stubAdoptedRingSource() + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable"))) + sut.checkAdoptedSource() + assertTrue(sut.adoptedSourceUnreachable.value) + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val result = sut.adoptRingIdentity(ringPubky) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertFalse(sut.adoptedSourceUnreachable.value) + } + @Test fun `adoptRingIdentity clears the previous identity while the new profile is unavailable`() = test { authenticateForTesting(publicKey = VALID_CONTACT_KEY_A, profileName = "Previous") + sut.wipeLocalState() + sut.addContact(VALID_CONTACT_KEY_B, existingProfile = PubkyProfile.placeholder(VALID_CONTACT_KEY_B)) + assertEquals(1, sut.contacts.value.size) val ringPubky = stubRingCredential() whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + whenever(pubkyService.contactRecords()).thenAnswer { throw TestAppError("Offline") } val result = sut.adoptRingIdentity(ringPubky) @@ -1177,7 +1629,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `wipe completes while adopted identity profile loading remains in flight`() = test { + fun `wipe waits for the adopted identity profile lookup and then clears the identity`() = test { sut.awaitInitialization() val ringPubky = stubRingCredential() whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) @@ -1192,17 +1644,15 @@ class PubkyRepoTest : BaseUnitTest() { val adoption = async { sut.adoptRingIdentity(ringPubky) } profileLoadStarted.await() - try { - val wipe = async { sut.wipeLocalState() } - wipe.await() - - assertFalse(adoption.isCompleted) - assertNull(sut.publicKey.value) - } finally { - finishProfileLoad.complete(Unit) - } + val wipe = async { sut.wipeLocalState() } + runCurrent() + val wipedDuringLookup = wipe.isCompleted + finishProfileLoad.complete(Unit) + wipe.await() - assertTrue(adoption.await().isFailure) + assertFalse(wipedDuringLookup) + assertEquals(true, adoption.await().getOrNull()) + assertNull(sut.publicKey.value) assertNull(sut.profile.value) assertTrue(sut.contacts.value.isEmpty()) verify(pubkyStore).reset() @@ -1221,17 +1671,23 @@ class PubkyRepoTest : BaseUnitTest() { } whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) val profileLoadStarted = CompletableDeferred() + val finishProfileLoad = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { profileLoadStarted.complete(Unit) - awaitCancellation() + finishProfileLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) } val adoption = async { sut.adoptRingIdentity(ringPubky) } profileLoadStarted.await() - adoption.cancelAndJoin() + adoption.cancel() + finishProfileLoad.complete(Unit) + adoption.join() + assertTrue(adoption.isCancelled) assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertEquals(reference, source) + assertFalse(profileSetupPending.value) verifyBlocking(pubkyService, never()) { signOut() } } @@ -1304,9 +1760,55 @@ class PubkyRepoTest : BaseUnitTest() { sut.initialize() assertFalse(sut.adoptedSourceLost.value) + assertTrue(sut.adoptedSourceUnreachable.value) verifyBlocking(pubkyService, never()) { clearSessionAccess() } } + @Test + fun `checkAdoptedSource should mark pubky ring reachable again without changing the identity`() = test { + val session = "saved_session" + val ringPubky = VALID_SELF_KEY.removePrefix("pubky") + stubAdoptedRingSource() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + whenever(pubkyService.importSession(session)).thenReturn(ringPubky) + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable"))) + sut.initialize() + assertTrue(sut.adoptedSourceUnreachable.value) + + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf(ringPubky))) + val result = sut.checkAdoptedSource() + + assertTrue(result.isSuccess) + assertFalse(sut.adoptedSourceUnreachable.value) + assertFalse(sut.adoptedSourceLost.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(sharedPubkyClient, never()) { ringCredential(any()) } + } + + @Test + fun `checkAdoptedSource should reset the unreachable flag when pubky ring drops the pubky`() = test { + stubAdoptedRingSource() + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable"))) + sut.checkAdoptedSource() + assertTrue(sut.adoptedSourceUnreachable.value) + + whenever(sharedPubkyClient.listRingIdentities()) + .thenReturn(Result.success(persistentListOf(VALID_CONTACT_KEY_A.removePrefix("pubky")))) + sut.checkAdoptedSource() + + assertTrue(sut.adoptedSourceLost.value) + assertFalse(sut.adoptedSourceUnreachable.value) + } + + @Test + fun `checkAdoptedSource should not query pubky ring without an adopted pubky`() = test { + val result = sut.checkAdoptedSource() + + assertTrue(result.isSuccess) + assertFalse(sut.adoptedSourceUnreachable.value) + verifyBlocking(sharedPubkyClient, never()) { listRingIdentities() } + } + @Test fun `checkAdoptedSource should clear an adopted identity removed from pubky ring after startup`() = test { stubAdoptedRingSource() @@ -1358,6 +1860,64 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { clearSessionAccess() } } + @Test + fun `stale ring listing failure does not mark a newly picked identity unreachable`() = test { + sut.awaitInitialization() + adoptedSource = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_CONTACT_KEY_B.removePrefix("pubky") + val listingStarted = CompletableDeferred() + val finishListing = CompletableDeferred() + whenever(sharedPubkyClient.listRingIdentities()).doSuspendableAnswer { + listingStarted.complete(Unit) + finishListing.await() + Result.failure(TestAppError("Unavailable")) + } + val sourceCheck = async { sut.checkAdoptedSource() } + listingStarted.await() + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val adoption = sut.adoptRingIdentity(ringPubky) + finishListing.complete(Unit) + sourceCheck.await() + + assertTrue(adoption.isSuccess) + assertEquals(RING_REFERENCE, adoptedSource) + assertFalse(sut.adoptedSourceUnreachable.value) + } + + @Test + fun `checkAdoptedSource should finish clearing a gone identity after caller cancellation`() = test { + val ringPubky = VALID_SELF_KEY.removePrefix("pubky") + stubAdoptedRingSource() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(pubkyService.importSession("saved_session")).thenReturn(ringPubky) + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf(ringPubky))) + sut.initialize() + val publicKeyBeforeCheck = sut.publicKey.value + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf())) + val clearStarted = CompletableDeferred() + val finishClear = CompletableDeferred() + whenever(pubkyService.clearSessionAccess()).doSuspendableAnswer { + withContext(NonCancellable) { + clearStarted.complete(Unit) + finishClear.await() + } + currentCoroutineContext().ensureActive() + } + + val sourceCheck = async { sut.checkAdoptedSource() } + clearStarted.await() + sourceCheck.cancel() + finishClear.complete(Unit) + sourceCheck.join() + + assertEquals(VALID_SELF_KEY, publicKeyBeforeCheck) + assertTrue(sourceCheck.isCancelled) + assertNull(sut.publicKey.value) + assertTrue(sut.adoptedSourceLost.value) + } + @Test fun `checkAdoptedSource should skip while initialization is running`() = test { val imported = CompletableDeferred() @@ -1678,6 +2238,35 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.hasIdentity()) } + @Test + fun `failed restoration retry keeps pubky ring unreachable until a check reaches it`() = test { + val session = "saved_session" + val ringPubky = VALID_SELF_KEY.removePrefix("pubky") + stubAdoptedRingSource() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + var canRestore = false + whenever(pubkyService.importSession(session)).thenAnswer { + if (canRestore) ringPubky else throw TestAppError("Offline") + } + whenever(sharedPubkyClient.ringCredential(ringPubky)).thenReturn(Result.failure(TestAppError("Unavailable"))) + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable"))) + sut.initialize() + assertTrue(sut.adoptedSourceUnreachable.value) + + sut.restoreSessionIfNeeded() + val unreachableAfterFailedRetry = sut.adoptedSourceUnreachable.value + canRestore = true + sut.restoreSessionIfNeeded() + val unreachableAfterRestore = sut.adoptedSourceUnreachable.value + whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf(ringPubky))) + sut.checkAdoptedSource() + + assertTrue(unreachableAfterFailedRetry) + assertTrue(unreachableAfterRestore) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertFalse(sut.adoptedSourceUnreachable.value) + } + @Test fun `adoption excludes a queued restoration retry`() = test { sut.awaitInitialization() @@ -1705,6 +2294,101 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, never()).importSession(any()) } + @Test + fun `restoration retry does not publish over a ring pick started while it runs`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + val restoreStarted = CompletableDeferred() + val finishRestore = CompletableDeferred() + whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { + restoreStarted.complete(Unit) + finishRestore.await() + VALID_CONTACT_KEY_A + } + val ringPubky = stubRingCredential() + val finishSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { finishSignIn.await() } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val retry = async { sut.restoreSessionIfNeeded() } + restoreStarted.await() + val adoption = async { sut.adoptRingIdentity(ringPubky) } + runCurrent() + val adoptedDuringRestore = adoption.isCompleted + finishRestore.complete(Unit) + retry.await() + val publicKeyAfterRetry = sut.publicKey.value + finishSignIn.complete(Unit) + + assertFalse(adoptedDuringRestore) + assertNull(publicKeyAfterRetry) + assertEquals(false, adoption.await().getOrNull()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(RING_REFERENCE, adoptedSource) + } + + @Test + fun `restoration retry skips while a ring pick waits for the lock`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + val ringPubky = stubRingCredential() + val firstSignInStarted = CompletableDeferred() + val failFirstSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + if (firstSignInStarted.isCompleted) return@doSuspendableAnswer + firstSignInStarted.complete(Unit) + failFirstSignIn.await() + throw TestAppError("Relay unavailable") + } + whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(true) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + + val failedPick = async { sut.adoptRingIdentity(ringPubky) } + firstSignInStarted.await() + val retry = async { sut.restoreSessionIfNeeded() } + val retriedPick = async { sut.adoptRingIdentity(ringPubky) } + failFirstSignIn.complete(Unit) + retry.await() + + assertEquals("Relay unavailable", failedPick.await().exceptionOrNull()?.message) + assertTrue(retriedPick.await().isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verify(pubkyService, never()).importSession(any()) + } + + @Test + fun `cold start publishes a restored identity over a ring pick queued behind it`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + val restoreStarted = CompletableDeferred() + val finishRestore = CompletableDeferred() + whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { + ServiceQueue.CORE.background { + restoreStarted.complete(Unit) + finishRestore.await() + VALID_CONTACT_KEY_A + } + } + val ringPubky = stubRingCredential() + + val initialization = async { sut.initialize() } + val restoreStartedInTime = restoreStarted.awaitStarted() + val pick = async { sut.adoptRingIdentity(ringPubky) } + runCurrent() + val pickQueued = !pick.isCompleted && !initialization.isCompleted + verifyBlocking(sharedPubkyClient, never()) { ringCredential(any()) } + finishRestore.complete(Unit) + initialization.await() + + assertTrue(restoreStartedInTime, "Timed out waiting for the restoration to start") + assertTrue(pickQueued, "Expected the ring pick to wait for the lock held by initialize") + assertEquals(PubkyAlreadySignedInError, pick.await().exceptionOrNull()) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(keychain, never()) { upsertString(eq(Keychain.Key.SHARED_PUBKY_SOURCE.name), any()) } + assertNull(adoptedSource) + } + @Test fun `wipe waits for restoration then prevents a queued retry from resurrecting identity`() = test { val credentials = mutableMapOf(Keychain.Key.PAYKIT_SESSION.name to "saved_session") @@ -2265,6 +2949,93 @@ class PubkyRepoTest : BaseUnitTest() { status = status, ) + private fun stubRingPickKeychain(vararg saved: Pair) { + saved.forEach { (key, value) -> ringKeychain[key.name] = value } + mapOf( + Keychain.Key.PAYKIT_SESSION.name to "session", + Keychain.Key.SHARED_PUBKY_SOURCE.name to "reference", + ).forEach { (key, label) -> + whenever(keychain.loadString(key)).thenAnswer { ringKeychain[key] } + whenever(keychain.exists(key)).thenAnswer { ringKeychain.containsKey(key) } + whenever { keychain.upsertString(eq(key), any()) }.doSuspendableAnswer { + currentCoroutineContext().ensureActive() + ringKeychain[key] = it.getArgument(1) + ringPickEvents += "save $label" + Unit + } + whenever { keychain.delete(key) }.doSuspendableAnswer { + currentCoroutineContext().ensureActive() + ringKeychain.remove(key) + ringPickEvents += "delete $label" + Unit + } + } + whenever { pubkyService.signOut() }.doSuspendableAnswer(ringTeardown("sign out")) + whenever { pubkyService.forgetSessionAccess() }.doSuspendableAnswer(ringTeardown("forget session")) + whenever { pubkyService.clearSessionAccess() }.doSuspendableAnswer(ringTeardown("clear session access")) + } + + private fun ringTeardown(event: String): suspend (InvocationOnMock) -> Unit = { + currentCoroutineContext().ensureActive() + ServiceQueue.CORE.background { + ringPickEvents += event + if (event in failingRingTeardowns) throw TestAppError("Offline") + ringKeychain.remove(Keychain.Key.PAYKIT_SESSION.name) + ringKeychain.remove(Keychain.Key.SHARED_PUBKY_SOURCE.name) + Unit + } + } + + private fun saveRingSession() { + ringKeychain[Keychain.Key.PAYKIT_SESSION.name] = "ring_session" + ringPickEvents += "save session" + } + + private fun stubRingSignInSavesSession() { + whenever { pubkyService.signIn("ring_secret") }.thenAnswer { saveRingSession() } + } + + private fun stubRingActivationFailure() { + whenever { pubkyService.signIn("ring_secret") }.thenAnswer { + saveRingSession() + throw TestAppError("Initialize failed") + } + } + + private suspend fun CompletableDeferred.awaitStarted(): Boolean = + withContext(Dispatchers.Default) { withTimeoutOrNull(STEP_TIMEOUT) { await() } } != null + + private fun assertRingPickCommitted(hasProfile: Boolean = false) { + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals("ring_session", ringKeychain[Keychain.Key.PAYKIT_SESSION.name]) + assertEquals(RING_REFERENCE, ringKeychain[Keychain.Key.SHARED_PUBKY_SOURCE.name]) + assertEquals(!hasProfile, profileSetupPending.value) + assertEquals(listOf("save reference", "save session"), ringPickEvents) + verifyBlocking(pubkyService) { contactRecords() } + assertTrue(sut.contactsLoadCompletionVersion.value > 0) + } + + private fun assertRingPickSignedOut() { + assertNull(sut.publicKey.value) + assertTrue(ringKeychain.isEmpty(), "Expected no saved session or reference, found '$ringKeychain'") + assertFalse(profileSetupPending.value) + } + + private fun assertRingPickAborted(keptReference: String) { + assertNull(sut.publicKey.value) + assertEquals("kept_session", ringKeychain[Keychain.Key.PAYKIT_SESSION.name]) + assertEquals(keptReference, ringKeychain[Keychain.Key.SHARED_PUBKY_SOURCE.name]) + assertTrue(ringPickEvents.isEmpty()) + verifyBlocking(keychain, never()) { upsertString(eq(Keychain.Key.SHARED_PUBKY_SOURCE.name), any()) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { hasIdentityRecord(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verifyBlocking(pubkyService, never()) { clearSessionAccess() } + } + private suspend fun stubRingCredential(): String { val ringPubky = VALID_SELF_KEY.removePrefix("pubky") whenever(sharedPubkyClient.ringCredential(ringPubky)).thenReturn(Result.success("ring_secret")) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 9ddf826083..3d990493f1 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -374,6 +374,39 @@ class PaykitSdkServiceTest { ) } + @Test + fun `session provider clears the session before the ring reference`() { + val blocking = mock() + val provider = sessionProvider(blocking) + + provider.clearSessionAccess() + + inOrder(blocking) { + verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) + verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + verify(blocking).delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + } + } + + @Test + fun `session provider keeps the ring reference when the session cannot be cleared`() { + val blocking = mock() + val provider = sessionProvider(blocking) + whenever(blocking.delete(Keychain.Key.PAYKIT_SESSION.name)).doAnswer { throw AppError("Delete failed") } + + assertFailsWith { provider.clearSessionAccess() } + + verify(blocking, never()).delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) + } + + private fun sessionProvider(blocking: Keychain.BlockingAccess): PaykitSdkSessionProvider { + val keychain = mock() + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + return PaykitSdkSessionProvider(keychain, mock()) + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index 8e5f659d6d..2d3e0afd81 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -19,6 +19,7 @@ import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -45,6 +46,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { private val publicKey = MutableStateFlow(null) private val backupStateVersion = MutableStateFlow(0L) private val identityRefreshVersion = MutableStateFlow(0L) + private val adoptedSourceUnreachable = MutableStateFlow(false) @Before fun setUp() = runBlocking { @@ -54,6 +56,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(pubkyRepo.backupStateVersion).thenReturn(backupStateVersion) whenever(pubkyRepo.identityRefreshVersion).thenReturn(identityRefreshVersion) + whenever(pubkyRepo.adoptedSourceUnreachable).thenReturn(adoptedSourceUnreachable) whenever(pubkyRepo.hasIdentity()).thenAnswer { publicKey.value != null } } @@ -124,7 +127,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { } @Test - fun `handler is disabled for a Ring managed identity`() = test { + fun `handler is disabled while the Ring pubky secret key is unavailable`() = test { isPaykitEnabled.value = true publicKey.value = "pubkyring" whenever(pubkyRepo.hasSecretKey()).thenReturn(false) @@ -135,6 +138,42 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { verifyComponentStates(authEnabled = false, signupEnabled = false) } + @Test + fun `handler is enabled when Pubky Ring becomes reachable again for the same pubky`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkyring" + whenever(pubkyRepo.hasSecretKey()).thenReturn(false) + createSut().start(backgroundScope) + runCurrent() + adoptedSourceUnreachable.value = true + runCurrent() + clearInvocations(packageManager) + + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + adoptedSourceUnreachable.value = false + runCurrent() + + verifyComponentStates(authEnabled = true, signupEnabled = false) + verify(pubkyRepo, times(3)).hasSecretKey() + } + + @Test + fun `handler is disabled when Pubky Ring becomes unreachable for the same pubky`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkyring" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + clearInvocations(packageManager) + + whenever(pubkyRepo.hasSecretKey()).thenReturn(false) + adoptedSourceUnreachable.value = true + runCurrent() + + verifyComponentStates(authEnabled = false, signupEnabled = false) + verify(pubkyRepo, times(2)).hasSecretKey() + } + @Test fun `authorization handler switches to signup when the local identity is removed`() = test { isPaykitEnabled.value = true diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index fece646fec..c45e12a39a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -2,17 +2,22 @@ package to.bitkit.ui.screens.profile import android.content.Context import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus @@ -177,6 +182,46 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { toastJob.cancel() } + @Test + fun `onIdentityClick opens the profile when another pick already signed in`() = test { + whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.failure(PubkyAlreadySignedInError)) + createSut() + val effects = mutableListOf() + val toasts = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + assertTrue(sut.uiState.value.navigateToProfile) + assertNull(sut.uiState.value.adoptingPubky) + assertTrue(effects.isEmpty()) + assertTrue(toasts.isEmpty()) + effectsJob.cancel() + toastJob.cancel() + } + + @Test + fun `onIdentityClick ignores another click while a pick is in progress`() = test { + val finishPick = CompletableDeferred>() + whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).doSuspendableAnswer { finishPick.await() } + createSut() + val effects = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + + sut.onIdentityClick(RING_PUBKY) + sut.onIdentityClick(RING_PUBKY) + finishPick.complete(Result.success(false)) + advanceUntilIdle() + + verifyBlocking(pubkyRepo, times(1)) { adoptRingIdentity(RING_PUBKY) } + assertEquals(PubkyChoiceEffect.NavigateToCreateProfile, effects.single()) + assertFalse(sut.uiState.value.navigateToProfile) + assertNull(sut.uiState.value.adoptingPubky) + effectsJob.cancel() + } + @Test fun `session restoration redirects to profile when already authenticated`() = test { isAuthenticated.value = true diff --git a/changelog.d/next/1393.fixed.md b/changelog.d/next/1393.fixed.md new file mode 100644 index 0000000000..0506db1ada --- /dev/null +++ b/changelog.d/next/1393.fixed.md @@ -0,0 +1 @@ +Going back while signing in with a Pubky Ring pubky now finishes the sign-in so profile setup can resume, a failed sign-in no longer leaves a hidden session behind, and Pubky authorization links reach Bitkit again once Pubky Ring is available, without a restart.