Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down
48 changes: 46 additions & 2 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ class PubkyRepo @Inject constructor(
}

private val initializationReady = CompletableDeferred<Unit>()
private val restoreInitializationLock = Any()
private var restoreInitialization: Pair<Long, Job>? = null

init {
scope.launch { initialize() }.invokeOnCompletion {
Expand All @@ -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<Unit> = 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: after a homeserver outage during restore, the own profile is never reloaded.

With a LocalSeed backup the identity is already active when scheduleInitializationAfterRestore() runs, so this line skips initializeSession() and calls loadIdentityData() straight away. If the homeserver is unreachable at that moment, loadProfile() fails once (Failed to load profile [… fetch profile], :857) and nothing retries it. restoreSessionLocked (:468) and awaitIdentityReady (:426) are the only automatic paths back into loadIdentityData(), and both return early because _publicKey is set. The header shows "Your Name" until the user opens Profile or restarts the app. On the merge-base, initialize() drops the identity during the outage, and the session-restore retry later brings it back together with the profile. The missing loadProfile retry is older; this PR makes it reachable.

Fix: when an identity is active and no profile is loaded, retry the profile. For example, in restoreSessionIfNeeded() (it runs on resume, on reconnect and from payment-request maintenance), launch loadProfile(), and loadContacts() if that load failed, when _publicKey.value != null && _profile.value == null. Alternatively, give loadProfile a bounded backoff retry on isPaykitTemporarilyUnavailable() failures. Unit test: identity active, the first resolveContactProfile throws transport_error, then restoreSessionIfNeeded() loads the profile.

Reproduction (device, 2 of 2 runs)

Gate_Throwaway emulator, head 0fd5e42. LocalSeed restore with the homeserver blackholed through a local fault proxy right after Full restore success, then released 10 s after the profile failure.

  • Head: the header still showed "Your Name" 300 s after release in both runs, although the homeserver was reachable again (Backup succeeded for: 'WALLET' 96 s and 95 s after release).
  • Merge-base 55b31ee, same steps: the name showed 19.6 s after release (Restored paykit session at 16:58:03.796).

Opening Profile or restarting the app shows the name.

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()
}

Expand Down Expand Up @@ -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<PubkyAuthRequest> = runSuspendCatching {
withContext(ioDispatcher) {
if (PubkyAuthRequest.isSignupUrl(authUrl) && !PubkyAuthRequest.isGrantSignupUrl(authUrl)) {
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ class WalletViewModel @Inject constructor(
connectionSession = connectionSession,
)
.getOrThrow()
pubkyRepo.initialize()
pubkyRepo.scheduleInitializationAfterRestore()
} else {
restoreFromMostRecentBackup(connectionSession)
}
Expand Down Expand Up @@ -441,7 +441,7 @@ class WalletViewModel @Inject constructor(
.getOrThrow()
}

pubkyRepo.initialize()
pubkyRepo.scheduleInitializationAfterRestore()
}

private suspend fun restoreFromRNRemoteBackup(connectionSession: WalletConnectionRecovery.Session) {
Expand Down
52 changes: 52 additions & 0 deletions app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ByteArray>()
verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture())
val backup = json.decodeFromString<WalletBackupV1>(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(
Expand Down
111 changes: 111 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>()
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<Unit>()
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<Unit>()
val releaseSecond = CompletableDeferred<Unit>()
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<String>()
Expand Down
28 changes: 24 additions & 4 deletions app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -424,16 +423,37 @@ 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<Unit>()
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)
sut.onBackupRestoreRetry()
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
Expand Down Expand Up @@ -608,7 +628,7 @@ class WalletViewModelTest : BaseUnitTest() {
walletState.value = walletState.value.copy(walletExists = true)
advanceUntilIdle()

verifyBlocking(pubkyRepo) { initialize() }
verify(pubkyRepo).scheduleInitializationAfterRestore()
}

@Test
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1686.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Wallet restore finishes without waiting for optional profile refresh, while incomplete identity data cannot replace complete backups.
2 changes: 1 addition & 1 deletion journeys/backup-restore/connection-recovery.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
<action>Keep required recovery unavailable and restart the app; verify the correction is retained and spending remains gated</action>
<action>Allow restoration of the old SETTINGS backup; verify only the explicitly corrected endpoints override that backup and unrelated preferences restore normally</action>
<action>Complete required WALLET and USDT recovery; verify ordinary wallet actions become available only after both acknowledgements</action>
<action>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</action>
<action>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</action>
</actions>
</journey>
18 changes: 18 additions & 0 deletions journeys/wallet/restore-during-identity-outage.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<journey name="wallet restore during optional identity outage">
<description>
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.
</description>
<actions>
<action>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</action>
<action>Trigger a full backup while the saved identity cannot be exported; verify failure retains the previous complete WALLET record and required pending backup work</action>
<action>Close the restore screen, release the identity fault and verify retained work completes and a fresh complete backup succeeds once</action>
<action>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</action>
<action>Restart during the outage; verify saved credentials resume recovery without duplicating a payment or contact mutation</action>
<action>Fail a required wallet or USDT restore instead; verify spending and address allocation remain gated and retry resumes the same required recovery</action>
</actions>
</journey>
Loading