diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 07ca2ff8a6..ad7339fc70 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -744,8 +744,8 @@ class BackupRepo @Inject constructor( // propagate and fail the backup rather than upload an empty set over the stored ones. val hwWalletNames = hwWalletStore.backupSnapshot().takeIf { it.isNotEmpty() } val cacheData = cacheStore.data.first() - val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) - val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null) + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrThrow() + val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrThrow() val payload = MetadataBackupV1( createdAt = currentTimeMillis(), @@ -783,6 +783,9 @@ class BackupRepo @Inject constructor( val privateRepo = privatePaykitRepo.get() val privateVersion = privateRepo.backupStateVersion.value val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() + check(paykitSdkBackupState.state != null || !pubkyRepo.hasIdentityForBackup()) { + "Paykit identity is unavailable for backup" + } val reservationRepo = privatePaykitAddressReservationRepo.get() val reservationVersion = reservationRepo.backupStateVersion.value val privateReservations = reservationRepo.backupSnapshot().getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 39a03aefb4..41854bbf9a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -333,6 +333,8 @@ class PubkyRepo @Inject constructor( } private val initializationReady = CompletableDeferred() + private val restoreInitializationLock = Any() + private var restoreInitialization: Pair? = null init { scope.launch { initialize() }.invokeOnCompletion { @@ -350,9 +352,43 @@ class PubkyRepo @Inject constructor( runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) } } - suspend fun initialize() = withContext(ioDispatcher) { - val restored = initializeMutex.withLock { initializeSession() } + suspend fun initialize() = initializeForRevision() + + fun scheduleInitializationAfterRestore() { + val job = synchronized(restoreInitializationLock) { + val revision = storedIdentityRevision.get() + restoreInitialization?.takeIf { it.first == revision && !it.second.isCompleted }?.second + ?: scope.launch(start = CoroutineStart.LAZY) { + initializeForRevision(revision) + }.also { pending -> + restoreInitialization = revision to pending + pending.invokeOnCompletion { + synchronized(restoreInitializationLock) { + if (restoreInitialization?.second === pending) restoreInitialization = null + } + } + } + } + job.start() + } + + private suspend fun initializeForRevision( + expectedRevision: Long? = null, + ): Result = withContext(ioDispatcher) { + val (restored, revision) = initializeMutex.withLock { + if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) { + return@withContext Result.success(Unit) + } + val restored = if (expectedRevision != null && _publicKey.value != null) { + true + } else { + initializeSession() + } + restored to storedIdentityRevision.get() + } + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) if (restored) loadIdentityData() + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) checkAdoptedSource() } @@ -1576,6 +1612,14 @@ class PubkyRepo @Inject constructor( !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() } + internal suspend fun hasIdentityForBackup(): Boolean = withContext(ioDispatcher) { + _publicKey.value != null || listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ).any { !keychain.loadString(it.name).isNullOrEmpty() } + } + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { if (PubkyAuthRequest.isSignupUrl(authUrl) && !PubkyAuthRequest.isGrantSignupUrl(authUrl)) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 2e32ea85f4..9ff6508c73 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -400,7 +400,7 @@ class WalletViewModel @Inject constructor( connectionSession = connectionSession, ) .getOrThrow() - pubkyRepo.initialize() + pubkyRepo.scheduleInitializationAfterRestore() } else { restoreFromMostRecentBackup(connectionSession) } @@ -441,7 +441,7 @@ class WalletViewModel @Inject constructor( .getOrThrow() } - pubkyRepo.initialize() + pubkyRepo.scheduleInitializationAfterRestore() } private suspend fun restoreFromRNRemoteBackup(connectionSession: WalletConnectionRecovery.Session) { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 4848b75228..79c44c5a73 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -191,9 +191,61 @@ class BackupRepoTest : BaseUnitTest() { }.thenReturn(Result.success(Unit)) whenever { vssBackupClient.listKeys() }.thenReturn(Result.success(emptyList())) + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(false) sut = createSut() } + @Test + fun `metadata backup preserves complete data when identity records cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("keychain unavailable") + whenever(pubkyRepo.snapshotSessionBackupState()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `metadata backup preserves complete data when contact overrides cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("store unavailable") + whenever(pubkyRepo.snapshotContactProfileOverrides()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `wallet backup preserves complete data while stored identity is unresolved`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(true) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.success(PrivatePaykitBackupSnapshot("fresh-sdk-state", 0)), + ) + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + val backup = json.decodeFromString(payload.firstValue.decodeToString()) + assertEquals("fresh-sdk-state", backup.paykitSdkBackupState) + } + + @Test + fun `wallet backup cannot treat unreadable identity as absent`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).doSuspendableAnswer { + throw IllegalStateException("keychain unavailable") + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `wallet backup waits for submission and includes the latest pending state`() = test { val statuses = MutableStateFlow( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1a5b05c58f..937806b02b 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -48,6 +48,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations @@ -2412,6 +2414,115 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(result.getOrNull()) } + @Test + fun `scheduled restore activation returns while authentication is held and coalesces duplicates`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + val release = CompletableDeferred() + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { release.await() } + clearInvocations(pubkyService) + try { + repeat(3) { sut.scheduleInitializationAfterRestore() } + runCurrent() + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + verify(pubkyService).importSession("restored_session") + } finally { + release.complete(VALID_SELF_KEY) + } + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verify(pubkyService).importSession("restored_session") + } + + @Test + fun `scheduled metadata refresh preserves an identity already activated by restore`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + assertTrue(sut.restoreSessionIfNeeded()) + val originalSignIn = assertNotNull(sut.currentSignIn()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + clearInvocations(pubkyService) + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { + throw PaykitException.Transport("transport_error", "controlled unavailable re-import") + } + val refreshed = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + refreshed.complete(Unit) + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Refreshed")) + } + sut.scheduleInitializationAfterRestore() + yield() + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isCurrent(originalSignIn)) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyService, never()).importSession(any()) + withTimeout(1.seconds) { refreshed.await() } + verify(pubkyService).resolveContactProfile(VALID_SELF_KEY, true) + } + + @Test + fun `new restored identity proceeds while old scheduled profile loading is held`() = test { + sut.awaitInitialization() + var session: String? = "first_session" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session } + whenever(keychain.delete(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session = null } + whenever(pubkyService.importSession("first_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.importSession("second_session")).thenReturn(VALID_CONTACT_KEY_B) + val releaseFirst = CompletableDeferred() + val releaseSecond = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + releaseFirst.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Obsolete")) + } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true)).doSuspendableAnswer { + releaseSecond.await() + createResolution(VALID_CONTACT_KEY_B, pubkyProfile = createPubkyProfile(name = "Current")) + } + try { + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + sut.restoreSessionBackupState(null).getOrThrow() + assertNull(sut.publicKey.value) + session = "second_session" + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + releaseFirst.complete(Unit) + runCurrent() + sut.scheduleInitializationAfterRestore() + runCurrent() + verify(pubkyService).importSession("second_session") + } finally { + releaseFirst.complete(Unit) + releaseSecond.complete(Unit) + } + advanceUntilIdle() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + assertEquals("Current", sut.profile.value?.name) + } + + @Test + fun `backup identity includes persisted and adopted credentials and fails on unreadable storage`() = test { + sut.awaitInitialization() + assertFalse(sut.hasIdentityForBackup()) + val identityKeys = listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ) + for (key in identityKeys) { + whenever(keychain.loadString(key.name)).thenReturn("stored") + assertTrue(sut.hasIdentityForBackup()) + whenever(keychain.loadString(key.name)).thenReturn(null) + } + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { throw TestAppError("unreadable") } + assertTrue(runSuspendCatching { sut.hasIdentityForBackup() }.isFailure) + } + @Test fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test { val imported = CompletableDeferred() diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 75e8318af2..ddd8ab351d 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -25,7 +25,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -424,9 +423,29 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `post restore profile failure allows proceeding only while safety markers stay clear`() = test { + fun `completed wallet restore does not wait for optional identity activation`() = test { + val release = CompletableDeferred() whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + whenever(pubkyRepo.initialize()).doSuspendableAnswer { + release.await() + Result.success(Unit) + } + try { + sut.onBackupRestoreRetry() + runCurrent() + assertEquals(RestoreState.Completed, sut.restoreState.value) + verify(pubkyRepo).scheduleInitializationAfterRestore() + verify(pubkyRepo, never()).initialize() + verify(backupRepo).setRestorePending(false) + } finally { + release.complete(Unit) + } + } + + @Test + fun `proceed rechecks safety markers after an optional restore failure`() = test { + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("metadata cache unavailable"))) sut.onBackupRestoreRetry() advanceUntilIdle() assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) @@ -434,6 +453,7 @@ class WalletViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(RestoreState.BackupFailed(2, hasPendingWalletRestore = false), sut.restoreState.value) assertEquals(null, sut.connectionStartupError.value) + verify(pubkyRepo, never()).scheduleInitializationAfterRestore() whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) var dismissed = false @@ -608,7 +628,7 @@ class WalletViewModelTest : BaseUnitTest() { walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - verifyBlocking(pubkyRepo) { initialize() } + verify(pubkyRepo).scheduleInitializationAfterRestore() } @Test diff --git a/changelog.d/next/1686.fixed.md b/changelog.d/next/1686.fixed.md new file mode 100644 index 0000000000..7d934b9487 --- /dev/null +++ b/changelog.d/next/1686.fixed.md @@ -0,0 +1 @@ +Wallet restore finishes without waiting for optional profile refresh, while incomplete identity data cannot replace complete backups. diff --git a/journeys/backup-restore/connection-recovery.xml b/journeys/backup-restore/connection-recovery.xml index 0df48ec1e4..8387b77917 100644 --- a/journeys/backup-restore/connection-recovery.xml +++ b/journeys/backup-restore/connection-recovery.xml @@ -17,6 +17,6 @@ Keep required recovery unavailable and restart the app; verify the correction is retained and spending remains gated Allow restoration of the old SETTINGS backup; verify only the explicitly corrected endpoints override that backup and unrelated preferences restore normally Complete required WALLET and USDT recovery; verify ordinary wallet actions become available only after both acknowledgements - Repeat with only optional profile caching unavailable after required recovery; verify that error retains its optional recovery controls and is not classified as failed node startup + Repeat with only optional profile enrichment unavailable after required recovery; verify ordinary wallet actions remain available and no failed-node-startup or restore-retry screen appears diff --git a/journeys/wallet/restore-during-identity-outage.xml b/journeys/wallet/restore-during-identity-outage.xml new file mode 100644 index 0000000000..fcaad5e534 --- /dev/null +++ b/journeys/wallet/restore-during-identity-outage.xml @@ -0,0 +1,18 @@ + + + Use a disposable test-network wallet with a complete VSS backup and a saved Pubky identity. + Required wallet, private reservation and USDT recovery imports must succeed before the injected + delay starts. Delay only final identity activation or subsequent profile/contact reads. + Keep metadata credential replacement and required restore imports available. These complete-app + fault controls are not currently provisioned; component test results are separate evidence. + Never use live funds or reset an existing wallet. + + + Restore the complete backup and hold final identity activation; verify ordinary wallet access resumes after required recovery completes, without reporting Paykit authenticated or payment-ready + Trigger a full backup while the saved identity cannot be exported; verify failure retains the previous complete WALLET record and required pending backup work + Close the restore screen, release the identity fault and verify retained work completes and a fresh complete backup succeeds once + Repeat with delayed profile/contact enrichment, then replace or remove the identity; verify late results cannot republish the old identity or overwrite current profile data + Restart during the outage; verify saved credentials resume recovery without duplicating a payment or contact mutation + Fail a required wallet or USDT restore instead; verify spending and address allocation remain gated and retry resumes the same required recovery + +