Skip to content

Commit fd71df5

Browse files
committed
fix: retry deferred sessions while foregrounded
1 parent 7bc3ccb commit fd71df5

5 files changed

Lines changed: 233 additions & 1 deletion

File tree

‎app/src/main/java/to/bitkit/repositories/PubkyRepo.kt‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,19 @@ class PubkyRepo @Inject constructor(
148148

149149
/** Shortest time between two contact list updates of a background profile refresh. */
150150
internal val CONTACT_REFRESH_BATCH_WINDOW = 300.milliseconds
151+
152+
/** Maximum automatic restore attempts during one foreground recovery window. */
153+
private const val DEFERRED_RESTORE_ATTEMPTS = 8
154+
155+
/** Delay after a deferred restore before another attempt may begin. */
156+
private val DEFERRED_RESTORE_INTERVAL = 5.seconds
151157
}
152158

153159
private val scope = appScope(ioDispatcher, TAG)
154160
private val serviceInitializeMutex = Mutex()
155161
private val initializeMutex = Mutex()
162+
private val deferredRestoreMutex = Mutex()
163+
private var deferredRestoreGeneration: Long? = null
156164
private val completedRestoreVersion = AtomicLong()
157165
private var completedRestoreSignInGeneration = -1L
158166
private val loadProfileMutex = Mutex()
@@ -340,6 +348,23 @@ class PubkyRepo @Inject constructor(
340348
restored
341349
}
342350

351+
/** Retries a temporarily unavailable saved session while the caller remains active. */
352+
suspend fun retryDeferredSessionRestoration() = withContext(ioDispatcher) {
353+
deferredRestoreMutex.withLock {
354+
awaitInitialization()
355+
repeat(DEFERRED_RESTORE_ATTEMPTS) {
356+
val generation = initializeMutex.withLock {
357+
deferredRestoreGeneration?.takeIf { it == signInGeneration.get() }
358+
} ?: return@withLock
359+
delay(DEFERRED_RESTORE_INTERVAL)
360+
if (restoreSession(expectedGeneration = generation)) {
361+
loadIdentityData()
362+
return@withLock
363+
}
364+
}
365+
}
366+
}
367+
343368
/**
344369
* Waits for a usable saved identity, sharing any in-flight restore. Later calls can retry failures.
345370
* Restoration waits for active identity work and runs in the repository scope, so cancelling the caller does not
@@ -368,9 +393,14 @@ class PubkyRepo @Inject constructor(
368393
}
369394
}
370395

371-
private suspend fun restoreSession(): Boolean {
396+
private suspend fun restoreSession(expectedGeneration: Long? = null): Boolean {
372397
val version = completedRestoreVersion.get()
373398
return initializeMutex.withLock {
399+
if (expectedGeneration != null &&
400+
(expectedGeneration != signInGeneration.get() || expectedGeneration != deferredRestoreGeneration)
401+
) {
402+
return@withLock false
403+
}
374404
if (completedRestoreVersion.get() != version &&
375405
completedRestoreSignInGeneration == signInGeneration.get()
376406
) {
@@ -401,6 +431,7 @@ class PubkyRepo @Inject constructor(
401431
ensureServiceInitialized(savedSession.getOrNull())
402432
}.onFailure {
403433
Logger.error("Failed to initialize paykit", it, context = TAG)
434+
deferredRestoreGeneration = signInGeneration.get().takeIf { _ -> it.isPaykitTemporarilyUnavailable() }
404435
if (notifyFailure && it.isPaykitIdentityError() && hasSavedSession()) {
405436
_sessionRestorationFailed.update { true }
406437
}
@@ -444,6 +475,7 @@ class PubkyRepo @Inject constructor(
444475
if (notifyFailure) _sessionRestorationFailed.update { result == InitResult.RestorationFailed }
445476
}
446477
}
478+
deferredRestoreGeneration = signInGeneration.get().takeIf { result == InitResult.RestorationDeferred }
447479
initializationReady.complete(Unit)
448480
return result is InitResult.Restored
449481
} finally {

‎app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ class AppViewModel @Inject constructor(
423423
paykitPaymentRequestRepo.setPaymentSubmissionActive(value)
424424
}
425425
private var paykitPaymentRequestPollingJob: Job? = null
426+
private var paykitSessionRestoreRetryJob: Job? = null
426427
private val paymentRequestPresentationRetryAttempts = mutableMapOf<PaykitPaymentRequestId, Int>()
427428
private val paymentRequestPresentationRetryJobs = mutableMapOf<PaykitPaymentRequestId, Job>()
428429
private val paymentRequestPresentationCompletionJobs = mutableMapOf<PaykitPaymentRequestId, Job>()
@@ -974,6 +975,7 @@ class AppViewModel @Inject constructor(
974975
isPaymentRequestPollingStopped = false
975976
if (paykitPaymentRequestPollingJob?.isActive == true) return
976977

978+
startPaykitSessionRestoreRetries()
977979
paykitPaymentRequestPollingJob = viewModelScope.launch {
978980
if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded()
979981
refreshIncomingPaykitPaymentRequests(messagePriority = Priority.Background)
@@ -1012,6 +1014,17 @@ class AppViewModel @Inject constructor(
10121014
}
10131015
}
10141016

1017+
private fun startPaykitSessionRestoreRetries() {
1018+
if (paykitSessionRestoreRetryJob?.isActive == true) return
1019+
paykitSessionRestoreRetryJob = viewModelScope.launch {
1020+
combine(isOnline, isPaykitEnabled) { connectivity, enabled ->
1021+
connectivity == ConnectivityState.CONNECTED && enabled
1022+
}.distinctUntilChanged().collectLatest { shouldRetry ->
1023+
if (shouldRetry && walletRepo.walletExists()) pubkyRepo.retryDeferredSessionRestoration()
1024+
}
1025+
}
1026+
}
1027+
10151028
private suspend fun refreshIdlePaykitContactEndpoints(expectedIdentity: String?) {
10161029
currentCoroutineContext().ensureActive()
10171030
if (expectedIdentity == null) return
@@ -1061,6 +1074,8 @@ class AppViewModel @Inject constructor(
10611074

10621075
fun stopPaykitPaymentRequestPolling() {
10631076
isPaymentRequestPollingStopped = true
1077+
paykitSessionRestoreRetryJob?.cancel()
1078+
paykitSessionRestoreRetryJob = null
10641079
clearPaymentRequestPreparation()
10651080
paykitPaymentRequestPollingJob?.cancel()
10661081
paykitPaymentRequestPollingJob = null

‎app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import kotlin.test.assertSame
9494
import kotlin.test.assertTrue
9595
import kotlin.time.Clock
9696
import kotlin.time.Duration.Companion.milliseconds
97+
import kotlin.time.Duration.Companion.seconds
9798
import kotlin.time.ExperimentalTime
9899
import kotlin.time.Instant
99100
import com.synonym.paykit.PubkyProfile as SdkPubkyProfile
@@ -2474,6 +2475,139 @@ class PubkyRepoTest : BaseUnitTest() {
24742475
verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name)
24752476
}
24762477

2478+
@Test
2479+
fun `deferred restoration recovers without another foreground caller`() = test {
2480+
val restore = stubSavedSessionRestore()
2481+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2482+
sut.initialize()
2483+
clearInvocations(pubkyService, keychain)
2484+
2485+
val retry = launch { sut.retryDeferredSessionRestoration() }
2486+
restore.answer = { VALID_SELF_KEY }
2487+
advanceTimeBy(5.seconds)
2488+
runCurrent()
2489+
retry.join()
2490+
2491+
assertEquals(VALID_SELF_KEY, sut.publicKey.value)
2492+
assertFalse(sut.sessionRestorationFailed.value)
2493+
verify(pubkyService).importSession("saved_session")
2494+
verify(pubkyService).contactRecords()
2495+
verify(keychain, never()).delete(any())
2496+
}
2497+
2498+
@Test
2499+
fun `deferred restoration bounds repeated failures and permits a later caller`() = test {
2500+
val restore = stubSavedSessionRestore()
2501+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2502+
sut.initialize()
2503+
clearInvocations(pubkyService)
2504+
2505+
sut.retryDeferredSessionRestoration()
2506+
2507+
verify(pubkyService, times(8)).importSession("saved_session")
2508+
assertNull(sut.publicKey.value)
2509+
assertFalse(sut.sessionRestorationFailed.value)
2510+
restore.answer = { VALID_SELF_KEY }
2511+
assertTrue(sut.restoreSessionIfNeeded())
2512+
}
2513+
2514+
@Test
2515+
fun `deferred restoration shares in-flight work with foreground callers`() = test {
2516+
val restore = stubSavedSessionRestore()
2517+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2518+
sut.initialize()
2519+
val finishRestore = CompletableDeferred<Unit>()
2520+
restore.answer = {
2521+
finishRestore.await()
2522+
VALID_SELF_KEY
2523+
}
2524+
clearInvocations(pubkyService)
2525+
2526+
val retry = launch { sut.retryDeferredSessionRestoration() }
2527+
advanceTimeBy(5.seconds)
2528+
runCurrent()
2529+
val duplicate = launch { sut.retryDeferredSessionRestoration() }
2530+
val foreground = async { sut.awaitIdentityReady() }
2531+
assertFalse(foreground.isCompleted)
2532+
finishRestore.complete(Unit)
2533+
2534+
assertEquals(PubkyIdentityReadiness.Ready, foreground.await())
2535+
retry.join()
2536+
duplicate.join()
2537+
verify(pubkyService).importSession("saved_session")
2538+
verify(pubkyService).contactRecords()
2539+
}
2540+
2541+
@Test
2542+
fun `deferred restoration stops when the identity is removed or replaced`() = test {
2543+
listOf("wipe", "sign out", "replace").forEachCase({ it }) { action ->
2544+
resetForCase()
2545+
val restore = stubSavedSessionRestore()
2546+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2547+
sut.initialize()
2548+
val retry = launch { sut.retryDeferredSessionRestoration() }
2549+
2550+
when (action) {
2551+
"wipe" -> sut.wipeLocalState()
2552+
"sign out" -> sut.signOut().getOrThrow()
2553+
"replace" -> authenticateForTesting(VALID_CONTACT_KEY_A, "other_session")
2554+
}
2555+
clearInvocations(pubkyService)
2556+
advanceUntilIdle()
2557+
retry.join()
2558+
2559+
verify(pubkyService, never()).importSession(any())
2560+
assertEquals(VALID_CONTACT_KEY_A.takeIf { action == "replace" }, sut.publicKey.value)
2561+
}
2562+
}
2563+
2564+
@Test
2565+
fun `deferred restoration stops on permanent failure and preserves credentials`() = test {
2566+
val restore = stubSavedSessionRestore()
2567+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2568+
sut.initialize()
2569+
restore.answer = { throw TestAppError("Revoked") }
2570+
clearInvocations(pubkyService, keychain)
2571+
2572+
sut.retryDeferredSessionRestoration()
2573+
2574+
verify(pubkyService).importSession("saved_session")
2575+
verify(keychain, never()).delete(any())
2576+
assertNull(sut.publicKey.value)
2577+
}
2578+
2579+
@Test
2580+
fun `cancelling deferred restoration cancels admission but lets active SDK work finish`() = test {
2581+
val restore = stubSavedSessionRestore()
2582+
restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") }
2583+
sut.initialize()
2584+
val finishRestore = CompletableDeferred<Unit>()
2585+
var completedSdkWork = false
2586+
restore.answer = {
2587+
withContext(NonCancellable) {
2588+
finishRestore.await()
2589+
completedSdkWork = true
2590+
}
2591+
VALID_SELF_KEY
2592+
}
2593+
clearInvocations(pubkyService)
2594+
2595+
val active = launch { sut.retryDeferredSessionRestoration() }
2596+
advanceTimeBy(5.seconds)
2597+
runCurrent()
2598+
val queued = launch { sut.retryDeferredSessionRestoration() }
2599+
queued.cancelAndJoin()
2600+
active.cancel()
2601+
assertFalse(active.isCompleted)
2602+
finishRestore.complete(Unit)
2603+
active.join()
2604+
2605+
assertTrue(completedSdkWork)
2606+
advanceUntilIdle()
2607+
verify(pubkyService).importSession("saved_session")
2608+
verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name)
2609+
}
2610+
24772611
@Test
24782612
fun `initialize should complete contacts load after contact fetch failure`() = test {
24792613
val session = "saved_session"

‎app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
914914
}
915915
}
916916

917+
@Test
918+
fun `deferred session recovery runs once while foreground online and enabled`() = test {
919+
enablePaykitUi()
920+
connectivityState.value = ConnectivityState.DISCONNECTED
921+
var activeRetries = 0
922+
var attempts = 0
923+
whenever(pubkyRepo.retryDeferredSessionRestoration()).doSuspendableAnswer {
924+
attempts++
925+
activeRetries++
926+
try {
927+
awaitCancellation()
928+
} finally {
929+
activeRetries--
930+
}
931+
}
932+
933+
sut.startPaykitPaymentRequestPolling()
934+
assertEquals(0, attempts)
935+
connectivityState.value = ConnectivityState.CONNECTED
936+
runCurrent()
937+
assertEquals(1, attempts)
938+
assertEquals(1, activeRetries)
939+
sut.startPaykitPaymentRequestPolling()
940+
assertEquals(1, attempts)
941+
942+
connectivityState.value = ConnectivityState.DISCONNECTED
943+
runCurrent()
944+
assertEquals(0, activeRetries)
945+
connectivityState.value = ConnectivityState.CONNECTED
946+
runCurrent()
947+
assertEquals(2, attempts)
948+
isPaykitEnabled.value = false
949+
runCurrent()
950+
assertEquals(0, activeRetries)
951+
952+
isPaykitEnabled.value = true
953+
runCurrent()
954+
assertEquals(3, attempts)
955+
sut.stopPaykitPaymentRequestPolling()
956+
runCurrent()
957+
assertEquals(0, activeRetries)
958+
clearInvocations(pubkyRepo)
959+
connectivityState.value = ConnectivityState.DISCONNECTED
960+
connectivityState.value = ConnectivityState.CONNECTED
961+
runCurrent()
962+
verify(pubkyRepo, never()).retryDeferredSessionRestoration()
963+
}
964+
917965
@Test
918966
fun `foreground maintenance retries a missing session until restored`() = test {
919967
enablePaykitUi()

‎journeys/paykit-clock-changes.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,8 @@ Network fault injection is not provided by the journey capability table. Use a d
3838
3. Re-enable connectivity while leaving Bitkit open. Verify that the same identity and contact list recover without signing out or restarting the app when the saved session or adopted Ring credential is still available. If the Ring credential is unavailable, select the same identity again from the normal identity choice flow.
3939
4. Repeat the failed startup and restore connectivity while Bitkit is backgrounded. Return to the foreground from a profile/contact screen and verify the same recovery. Resume must work from any screen, not only Home.
4040
5. Start adopting a Ring identity while recovery is pending. Verify that automatic restoration does not replace the selected identity. Explicit sign-out or wallet reset must not be undone by a pending restoration.
41+
6. While online, use an isolated shared-state lock holder to make a saved-session restore return temporarily unavailable. Keep Bitkit in the foreground without navigating, reconnecting, or resuming it. Release the held lock during the short retry window and verify recovery without another action. Android makes up to eight retries with a five-second delay between completed attempts; SDK work and safety waits can take additional time. After this window, the existing maintenance and foreground/network triggers remain available. Backgrounding the app or disabling Paykit must stop queued retries, without interrupting an active SDK write. Repeat with a permanent credentials failure and verify it does not enter this short retry loop.
4142

4243
Both platforms retry automatically on connectivity restoration and app resume. A valid saved session must recover without a new authorization. Android can also recover with an available adopted Ring credential.
44+
45+
The controlled lock-holder check needs a separate fixture and is not established by the unit tests for retry scheduling.

0 commit comments

Comments
 (0)