From f21f86ea5cf0e54d09dd060d9c991f35d7838583 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 22:44:40 +0300 Subject: [PATCH 1/6] fix: recover paykit after clock changes --- ...PaykitSubscriptionNotificationScheduler.kt | 5 ++ .../java/to/bitkit/repositories/PubkyRepo.kt | 31 +++++++-- .../to/bitkit/services/PaykitSdkService.kt | 23 +++++-- ...itSubscriptionNotificationSchedulerTest.kt | 16 +++++ .../repositories/PaykitSubscriptionTest.kt | 28 ++++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 65 +++++++++++++++---- .../bitkit/services/PaykitSdkServiceTest.kt | 22 +++++++ .../services/PubkyIdentityRepublishTest.kt | 20 ++++++ changelog.d/next/1339.fixed.md | 1 + journeys/README.md | 2 + journeys/paykit-clock-changes.md | 25 +++++++ 11 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 changelog.d/next/1339.fixed.md create mode 100644 journeys/paykit-clock-changes.md diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 63d371cae2..a9b91fbd0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -152,8 +152,13 @@ class PaykitSubscriptionWorkClient @Inject constructor( class PaykitSubscriptionNotificationWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, + private val clock: Clock, ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { + val startsAt = inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: return Result.failure() + if (startsAt > clock.now()) return Result.retry() if (App.currentActivity?.value != null) return Result.success() applicationContext.pushNotification( title = applicationContext.getString(R.string.subscriptions__payment_due_title), diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 17817cdc95..9ad4ae8b0a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -215,7 +215,7 @@ class PubkyRepo @Inject constructor( Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) } is InitResult.RestorationFailed -> { - clearAuthenticatedState() + clearAuthenticatedState(clearCachedProfile = false) _sessionRestorationFailed.update { true } } } @@ -319,8 +319,8 @@ class PubkyRepo @Inject constructor( waitForAuthApproval(attemptId) withContext(ioDispatcher) { withContext(NonCancellable) { + completeAuthPreservingExistingSession() shouldRevokeSessionOnFailure = true - pubkyService.completeAuth() } ensureAuthAttemptActive(attemptId) val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { @@ -373,6 +373,25 @@ class PubkyRepo @Inject constructor( } } + private suspend fun completeAuthPreservingExistingSession() { + val previousSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + var completed = false + try { + pubkyService.completeAuth() + completed = true + } finally { + if (!completed) { + val installedSession = runSuspendCatching { + val currentSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + currentSession != null && currentSession != previousSession + }.onFailure { + Logger.warn("Failed to identify incomplete Pubky auth session", it, context = TAG) + }.getOrDefault(false) + revokeCompletedAuthSessionIfNeeded(installedSession) + } + } + } + private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) { if (!shouldRevokeSession) return discardAbandonedSession() @@ -1401,9 +1420,11 @@ class PubkyRepo @Inject constructor( _backupStateVersion.update { it + 1 } } - private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { - evictPubkyImages() - runSuspendCatching { pubkyStore.reset() } + private suspend fun clearAuthenticatedState(clearCachedProfile: Boolean = true) = withContext(ioDispatcher) { + if (clearCachedProfile) { + evictPubkyImages() + runSuspendCatching { pubkyStore.reset() } + } _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 15a47473a1..4bc35b78ea 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -185,6 +185,7 @@ class PaykitSdkService @Inject constructor( private val identityRepublishMutex = Mutex() private var republishPublicKey: String? = null private var nextIdentityRepublishAt = 0L + private var lastIdentityRepublishAt = 0L private val handleMutex = Mutex() private val operationMutex = Mutex() private val setupMutex = Mutex() @@ -270,9 +271,16 @@ class PaykitSdkService @Inject constructor( if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context) val key = publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret) val identity = key?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching - if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching + if ( + identity == republishPublicKey && + now >= lastIdentityRepublishAt && + now < nextIdentityRepublishAt + ) { + return@runSuspendCatching + } republishPublicKey = identity + lastIdentityRepublishAt = now nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds if (bootstrap().republishIdentity(identity)) { nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds @@ -980,12 +988,13 @@ class PaykitSdkService @Inject constructor( } private suspend fun currentSdkStatePublicKeyLocked(): String? { - return runSuspendCatching { handle().identityStatus()?.publicKey } - .getOrElse { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - resetRuntime() - null - } + // Read the persisted owner without restoring the grant we are about to replace. + sessionProvider.suspendStoredSessionAccess() + return try { + handle().identityStatus()?.publicKey + } finally { + sessionProvider.resumeStoredSessionAccess() + } } private suspend fun persistSessionAccess( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index d21feac42c..576b8b1446 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -5,8 +5,12 @@ package to.bitkit.repositories import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker import androidx.work.OneTimeWorkRequest +import androidx.work.WorkerParameters +import androidx.work.workDataOf import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -16,6 +20,7 @@ import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT @@ -88,6 +93,17 @@ class PaykitSubscriptionNotificationSchedulerTest { ) } + @Test + fun `worker defers a reminder when clock is before the billing period`() = runTest { + val params = mock() + whenever(params.inputData).thenReturn( + workDataOf(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to NEXT_PERIOD_START.toString()), + ) + val worker = PaykitSubscriptionNotificationWorker(context, params, clock) + + assertEquals(ListenableWorker.Result.retry(), worker.doWork()) + } + @Test fun `synchronize cancels work no longer required`() { sut.synchronize( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt index 5923c06141..dc9d822b82 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -4,6 +4,7 @@ package to.bitkit.repositories import com.synonym.paykit.PaymentRequestLifecycleState import org.junit.Test +import java.util.TimeZone import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -11,6 +12,33 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant class PaykitSubscriptionTest { + @Test + fun `travel and daylight saving changes preserve UTC billing boundaries`() { + val original = TimeZone.getDefault() + try { + for (zone in listOf("America/New_York", "Pacific/Kiritimati", "Pacific/Pago_Pago")) { + TimeZone.setDefault(TimeZone.getTimeZone(zone)) + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Day, + startsAt = Instant.parse("2027-03-13T08:00:00Z"), + anchor = Instant.parse("2027-03-13T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.upcomingPeriodsAfter(Instant.parse("2027-03-13T09:00:00Z"), 2) + + assertEquals( + listOf(Instant.parse("2027-03-14T08:00:00Z"), Instant.parse("2027-03-15T08:00:00Z")), + periods.map { it.startsAt }, + zone, + ) + } + } finally { + TimeZone.setDefault(original) + } + } + @Test fun `monthly recurrence returns to anchor day after a short month`() { val recurrence = PaykitSubscriptionRecurrence( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 5b8d001cea..44e4fd00e6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -32,6 +32,7 @@ import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -316,18 +317,31 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `completeAuthentication should reset state on failure`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") } - - val authRequest = startAuthForTesting() - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertTrue(result.isFailure) - assertFalse(sut.isAuthenticated.value) - assertNull(sut.publicKey.value) - verifyBlocking(pubkyService) { signOut() } + fun `failed authentication only revokes a session installed by that attempt`() = test { + for (sessionState in listOf("unchanged", "replaced", "unreadable")) { + var session = "existing-session" + var completionFailed = false + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { + check(!completionFailed || sessionState != "unreadable") { "unavailable" } + session + } + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + doAnswer { + if (sessionState != "unchanged") session = "new-session" + completionFailed = true + throw TestAppError("Clock skew") + }.whenever(pubkyService).completeAuth() + clearInvocations(pubkyService) + + val authRequest = startAuthForTesting() + approveAuthForTesting(authRequest) + val result = sut.completeAuthentication() + + assertTrue(result.isFailure) + assertFalse(sut.isAuthenticated.value) + assertNull(sut.publicKey.value) + verifyBlocking(pubkyService, times(if (sessionState == "replaced") 1 else 0)) { signOut() } + } } @Test @@ -1444,6 +1458,33 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `failed restoration preserves profile data and credentials for retry`() = test { + val session = "saved_session" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret") + var canRestore = false + whenever(pubkyService.importSession(session)).thenAnswer { + if (canRestore) VALID_SELF_KEY else throw TestAppError("Clock skew") + } + whenever(pubkyService.signIn("local_secret")).thenAnswer { throw TestAppError("Clock skew") } + clearInvocations(pubkyStore, keychain) + + sut.initialize() + + assertTrue(sut.sessionRestorationFailed.value) + assertFalse(sut.isAuthenticated.value) + verify(pubkyStore, never()).reset() + verifyBlocking(keychain, never()) { delete(any()) } + + canRestore = true + sut.initialize() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + assertFalse(sut.sessionRestorationFailed.value) + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 005e78b71e..5bad7e4e52 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyLocalSecretKey @@ -92,6 +93,27 @@ class PaykitSdkServiceTest { } } + @Test + fun `identity lookup failure preserves stored state and stops activation`() = runTest { + for (error in listOf( + PaykitException.Identity("identity_error", "restore Pubky grant session from platform provider"), + PaykitException.Storage("storage_error", "unavailable"), + )) { + val keychain = mock() + val sdk = mock() + whenever(sdk.identityStatus()).thenThrow(error) + val service = PaykitSdkService(mock(), keychain) { sdk } + + val thrown = assertFailsWith { + service.activateRegisteredIdentity(PubkySessionBootstrapResult(mock(), "pubky_test")) + } + + assertEquals(error, thrown) + verify(keychain, never()).delete(any()) + verify(keychain, never()).upsertString(any(), any()) + } + } + private val basePubkyClientConfig = PubkyClientConfig( requestTimeoutSecs = 30uL, localTestnetHost = null, diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index a85a165b69..da540f0e1b 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -32,6 +32,26 @@ import kotlin.test.assertTrue class PubkyIdentityRepublishTest { private val publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + @Test + fun `clock rollback retries publication and then resumes throttling`() = runTest { + for (published in listOf(true, false)) { + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).thenReturn(published) + val service = PaykitSdkService( + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } + + service.republishIdentityIfNeeded(publicKey, now = 2_592_000_000) + service.republishIdentityIfNeeded(publicKey, now = 0) + service.republishIdentityIfNeeded(publicKey, now = 1_000) + + verify(bootstrap, times(2)).republishIdentity("pubky$publicKey") + } + } + @Test fun `slow publication survives caller deadline and establishes success throttle`() = runTest { val bootstrap = mock() diff --git a/changelog.d/next/1339.fixed.md b/changelog.d/next/1339.fixed.md new file mode 100644 index 0000000000..f2384e1109 --- /dev/null +++ b/changelog.d/next/1339.fixed.md @@ -0,0 +1 @@ +Fixed Paykit recovery after device clock changes to preserve contacts and improve retry and billing reminder timing. diff --git a/journeys/README.md b/journeys/README.md index fea768f346..5a8de388f8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -221,3 +221,5 @@ One asymmetry worth knowing when comparing: Android builds `Tab-*` from the enum (`CustomTabRowWithSpacing`), so `Tab-all` is stable in any locale, while iOS derives it from the tab's display name and becomes `Tab-todas` in Spanish. Journeys naming a `Tab-*` identifier assume an English device for iOS's sake. + +Device-clock fault injection requires a separate manual run: [Paykit clock changes](paykit-clock-changes.md). diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md new file mode 100644 index 0000000000..3784bf2714 --- /dev/null +++ b/journeys/paykit-clock-changes.md @@ -0,0 +1,25 @@ +# Paykit clock changes — manual fault injection + +Device-clock control is not provided by the journey runner's capability table. Run these checks on disposable test identities and test wallets using a device or isolated environment whose clock can be changed without changing the developer host clock. + +## Setup + +Create a fresh wallet and a matching Pubky identity, save a contact, and link a second test identity for private payments. Record the profile, contact, receiving address, and wallet balance. Cover both a local-secret identity and a Ring-authorized session. Enable notifications and accept a recurring subscription with a known UTC billing boundary. + +## Clock skew and recovery + +1. Move the test clock one month forward. Relaunch Bitkit and attempt a Paykit operation. An authentication failure is allowed; the app must not treat it as authorization to erase the saved identity, contacts, or wallet. +2. Attempt to restore the session while the clock is wrong. Restore the correct clock and retry, then relaunch. If a grant has expired or been revoked, authorize the same identity again in Ring. Do not sign out or reset the wallet as part of recovery. +3. Verify that the original contact, profile, receiving address, and balance are still present, and that private payment requests can be exchanged again. A new payment must still require normal approval. +4. Repeat with a backward clock change. After correcting the clock, verify that identity publication and payment-request presentation retry normally instead of waiting for the old future timestamp. +5. Separately verify that explicitly signing out and switching identities retains the normal isolation between identities. + +## Travel, daylight saving, and reminders + +1. Keep automatic date/time enabled and change only the timezone between America/New_York, Pacific/Kiritimati, and Pacific/Pago_Pago. Authentication and the subscription's UTC billing boundary must remain unchanged; local date/time labels may change. +2. Include a subscription spanning a daylight-saving transition. Verify the agreed UTC boundary rather than assuming the local wall-clock hour stays constant. +3. Schedule a reminder, then move the clock backward before it is due. It must not announce that payment is due while the device's current time is before that billing boundary. +4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. +5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. + +These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From 9890462f598bbd11cc428ef28f574efa24e8cfd7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 23:08:39 +0300 Subject: [PATCH 2/6] fix: isolate pubky cache when identity changes --- .../java/to/bitkit/repositories/PubkyRepo.kt | 9 +++ .../to/bitkit/services/PaykitSdkService.kt | 6 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 7 +- .../bitkit/services/PaykitSdkServiceTest.kt | 68 ++++++++++++++++++- .../services/PubkyIdentityRepublishTest.kt | 43 ++++++++++-- journeys/paykit-clock-changes.md | 3 +- 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 9ad4ae8b0a..c26cd482ee 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -353,6 +353,7 @@ class PubkyRepo @Inject constructor( if (_approvedAuthAttemptId.value == attemptId) { _approvedAuthAttemptId.update { null } } + clearProfileIfIdentityChanged(pk) _publicKey.update { pk } _authState.update { PubkyAuthState.Authenticated } shouldRevokeSessionOnFailure = false @@ -373,6 +374,14 @@ class PubkyRepo @Inject constructor( } } + private suspend fun clearProfileIfIdentityChanged(publicKey: String) { + if (_publicKey.value == publicKey) return + _contactsLoadVersion.update { 0L } + _profile.update { null } + _contacts.update { emptyList() } + clearPendingImport() + } + private suspend fun completeAuthPreservingExistingSession() { val previousSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) var completed = false diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 4bc35b78ea..0c5ad06d31 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -86,6 +86,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.async.BaseCoroutineScope +import to.bitkit.data.PubkyStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env @@ -169,6 +170,7 @@ internal object PaykitReceiverPaths { class PaykitSdkService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, + private val pubkyStore: PubkyStore, @IoDispatcher ioDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(ioDispatcher, TAG) { private val stateStore = PaykitSdkStateBlobStore(keychain) @@ -208,10 +210,11 @@ class PaykitSdkService @Inject constructor( internal constructor( context: Context, keychain: Keychain, + pubkyStore: PubkyStore, bootstrapFactory: (() -> PubkySessionBootstrap)? = null, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, sdkFactory: () -> PaykitSdk, - ) : this(context, keychain, ioDispatcher) { + ) : this(context, keychain, pubkyStore, ioDispatcher) { this.sdkFactory = sdkFactory if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory isSetup.complete(Unit) @@ -1019,6 +1022,7 @@ class PaykitSdkService @Inject constructor( persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret) sessionProvider.setLiveSessionAccess(result.sessionAccess) if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { + if (previousPublicKey != null) pubkyStore.reset() keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } resetRuntime() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 44e4fd00e6..54901613d8 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1704,7 +1704,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.loadProfile() assertEquals(newPublicKey.ensurePubkyPrefixForTest(), sut.publicKey.value) - assertEquals("Initial Old", sut.profile.value?.name) + assertNull(sut.profile.value) } @Test @@ -1749,9 +1749,8 @@ class PubkyRepoTest : BaseUnitTest() { val contacts = sut.contacts.value assertEquals(newPublicKey.ensurePubkyPrefixForTest(), sut.publicKey.value) - assertEquals(1, contacts.size) - assertEquals(existingContact.publicKey, contacts.first().publicKey) - assertEquals(existingContact.name, contacts.first().name) + assertTrue(contacts.isEmpty()) + assertEquals(0L, sut.contactsLoadVersion.value) } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 5bad7e4e52..3b16a438da 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,11 +2,13 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicContactSharingPolicy import com.synonym.paykit.ReceiverNoiseSecretKey @@ -20,10 +22,13 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.PubkyStore +import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain import to.bitkit.ext.fromHex import to.bitkit.ext.toHex import to.bitkit.models.PubkyAuthRequestError +import to.bitkit.models.PubkyProfileData import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertContentEquals @@ -66,7 +71,7 @@ class PaykitSdkServiceTest { "initialize", "cancel" -> whenever(sdk.initialize()).thenThrow(error) } var handlesCreated = 0 - val service = PaykitSdkService(mock(), keychain) { + val service = PaykitSdkService(mock(), keychain, mock()) { handlesCreated++ sdk } @@ -102,7 +107,7 @@ class PaykitSdkServiceTest { val keychain = mock() val sdk = mock() whenever(sdk.identityStatus()).thenThrow(error) - val service = PaykitSdkService(mock(), keychain) { sdk } + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } val thrown = assertFailsWith { service.activateRegisteredIdentity(PubkySessionBootstrapResult(mock(), "pubky_test")) @@ -114,6 +119,65 @@ class PaykitSdkServiceTest { } } + @Test + fun `activation isolates cached identity data and preserves same owner or legacy backup`() = runTest { + val originalKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val differentKey = "5" + originalKey.drop(1) + val cases = listOf(originalKey, "pubky$originalKey", differentKey, null).map { it to false } + + (differentKey to true) + for ((previousKey, resetFails) in cases) { + val keychain = mock() + val blocking = mock() + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) + .thenReturn(ByteArray(32) { 1 }) + val sdk = mock() + whenever(sdk.identityStatus()).thenReturn(IdentityStatus(previousKey, false)) + val originalCache = PubkyStoreData( + cachedName = "Original profile", + cachedImageUri = "pubky://original/avatar", + contactProfileOverrides = mapOf(originalKey to PubkyProfileData("Private label", "")), + ) + var cache = originalCache + val store = mock() + val resetError = AppError("Cache unavailable") + whenever(store.reset()).thenAnswer { + if (resetFails) throw resetError + cache = PubkyStoreData() + } + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).thenReturn(true) + val access = mock() + val noise = mock() + whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) + whenever(access.exportSessionSecret()).thenReturn("new-session") + whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } + + val result = PubkySessionBootstrapResult(access, "pubky$originalKey") + if (resetFails) { + assertEquals(resetError, assertFailsWith { service.activateRegisteredIdentity(result) }) + verify(sdk, never()).initialize() + assertEquals(originalCache, cache) + continue + } + service.activateRegisteredIdentity(result) + + if (previousKey == differentKey) { + assertEquals(PubkyStoreData(), cache) + inOrder(store, sdk) { + verify(store).reset() + verify(sdk).initialize() + } + } else { + assertEquals(originalCache, cache) + verify(store, never()).reset() + } + } + } + private val basePubkyClientConfig = PubkyClientConfig( requestTimeoutSecs = 30uL, localTestnetHost = null, diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index da540f0e1b..4037beda38 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -38,6 +38,7 @@ class PubkyIdentityRepublishTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(published) val service = PaykitSdkService( + mock(), mock(), mock(), { bootstrap }, @@ -66,7 +67,13 @@ class PubkyIdentityRepublishTest { cancelled = !currentCoroutineContext().isActive } } - val service = PaykitSdkService(mock(), mock(), { bootstrap }, StandardTestDispatcher(testScheduler)) { mock() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -92,6 +99,7 @@ class PubkyIdentityRepublishTest { val service = PaykitSdkService( context = mock(), keychain = mock(), + pubkyStore = mock(), bootstrapFactory = { factories++ bootstrap @@ -120,6 +128,7 @@ class PubkyIdentityRepublishTest { val service = PaykitSdkService( context = mock(), keychain = mock(), + pubkyStore = mock(), bootstrapFactory = { bootstrap }, ioDispatcher = StandardTestDispatcher(testScheduler), sdkFactory = { mock() }, @@ -138,7 +147,13 @@ class PubkyIdentityRepublishTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(true) val sdk = mock() - val service = PaykitSdkService(mock(), mock(), { bootstrap }, StandardTestDispatcher(testScheduler)) { sdk } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { sdk } val otherKey = publicKey.dropLast(1) + "y" service.republishIdentityIfNeeded(publicKey, now = 0) @@ -155,7 +170,13 @@ class PubkyIdentityRepublishTest { val gate = CompletableDeferred() val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { gate.await() } - val service = PaykitSdkService(mock(), mock(), { bootstrap }, StandardTestDispatcher(testScheduler)) { mock() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) } runCurrent() @@ -177,7 +198,13 @@ class PubkyIdentityRepublishTest { cancelled = true } } - val service = PaykitSdkService(mock(), mock(), { bootstrap }, StandardTestDispatcher(testScheduler)) { mock() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) assertEquals(5_000L, currentTime) @@ -204,7 +231,13 @@ class PubkyIdentityRepublishTest { val gate = CompletableDeferred() val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { gate.await() } - val service = PaykitSdkService(mock(), mock(), { bootstrap }, StandardTestDispatcher(testScheduler)) { mock() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } var continued = false val cancelledCaller = async { cancel() diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 3784bf2714..be49f593b4 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -12,7 +12,8 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 2. Attempt to restore the session while the clock is wrong. Restore the correct clock and retry, then relaunch. If a grant has expired or been revoked, authorize the same identity again in Ring. Do not sign out or reset the wallet as part of recovery. 3. Verify that the original contact, profile, receiving address, and balance are still present, and that private payment requests can be exchanged again. A new payment must still require normal approval. 4. Repeat with a backward clock change. After correcting the clock, verify that identity publication and payment-request presentation retry normally instead of waiting for the old future timestamp. -5. Separately verify that explicitly signing out and switching identities retains the normal isolation between identities. +5. After a failed restoration, open the profile from the home header and authorize the same identity through Ring without signing out. The recovery flow must remain reachable and preserve the profile and contact labels. +6. Repeat failed restoration, then authorize a different identity through Ring. Even if its profile cannot load, the previous identity's name, avatar, and contact labels must not appear. Also verify normal explicit sign-out and identity switching. ## Travel, daylight saving, and reminders From 8a1a7cb486477ca7be815a0a3eebffb8d6961325 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 17:13:06 +0300 Subject: [PATCH 3/6] fix: retry saved paykit sessions after connection loss --- .../java/to/bitkit/repositories/PubkyRepo.kt | 164 ++++++++++-------- .../services/PubkyAuthHandlerRegistrar.kt | 32 ++-- .../main/java/to/bitkit/ui/MainActivity.kt | 5 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 + .../to/bitkit/repositories/PubkyRepoTest.kt | 126 +++++++++++++- .../services/PubkyAuthHandlerRegistrarTest.kt | 32 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 15 ++ changelog.d/next/1339.fixed.md | 2 +- journeys/paykit-clock-changes.md | 12 ++ 9 files changed, 299 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index c26cd482ee..c839ce9357 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -179,53 +179,61 @@ class PubkyRepo @Inject constructor( } suspend fun initialize() = withContext(ioDispatcher) { + initializeMutex.withLock { initializeSession() } + } + + suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { + awaitInitialization() + initializeMutex.withLock { + if (_publicKey.value != null || _authState.value != PubkyAuthState.Idle) return@withLock + runSuspendCatching { + if (hasIdentity()) initializeSession() + }.onFailure { Logger.warn("Failed to retry paykit session restoration", it, context = TAG) } + } + } + + private suspend fun initializeSession() { runSuspendCatching { ensureServiceInitialized() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) if (it.isPaykitIdentityError() && hasSavedSession()) _sessionRestorationFailed.update { true } - }.getOrNull() ?: return@withContext + }.getOrNull() ?: return - initializeMutex.withLock { - _sessionRestorationFailed.update { false } - val result = runSuspendCatching { - val savedSessionSecret = runCatching { - keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - }.getOrNull() - val storedSecretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() - - resolveSessionInitialization( - savedSessionSecret = savedSessionSecret, - storedSecretKeyHex = storedSecretKeyHex, - ) - }.onFailure { - Logger.error("Failed to initialize paykit", it, context = TAG) - }.getOrNull() ?: return@withLock + _sessionRestorationFailed.update { false } + val result = runSuspendCatching { + val savedSessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - when (result) { - is InitResult.NoSession -> { - clearAuthenticatedState() - Logger.debug("Found no saved paykit session", context = TAG) - } - is InitResult.Restored -> { - _publicKey.update { result.publicKey } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) - } - is InitResult.RestorationFailed -> { - clearAuthenticatedState(clearCachedProfile = false) - _sessionRestorationFailed.update { true } - } - } - initializationReady.complete(Unit) + resolveSessionInitialization( + savedSessionSecret = savedSessionSecret, + storedSecretKeyHex = storedSecretKeyHex, + ) + }.onFailure { + Logger.error("Failed to initialize paykit", it, context = TAG) + }.getOrElse { InitResult.RestorationFailed } - if (result is InitResult.Restored) { - loadProfile() - loadContacts() + when (result) { + is InitResult.NoSession -> { + clearAuthenticatedState() + Logger.debug("Found no saved paykit session", context = TAG) + } + is InitResult.Restored -> { + _publicKey.update { result.publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) + } + is InitResult.RestorationFailed -> { + clearAuthenticatedState(clearCachedProfile = false) + _sessionRestorationFailed.update { true } } } + initializationReady.complete(Unit) + + if (result is InitResult.Restored) { + loadProfile() + loadContacts() + } } private fun hasSavedSession(): Boolean = runCatching { @@ -291,12 +299,12 @@ class PubkyRepo @Inject constructor( // region Ring auth flow - suspend fun startAuthentication(): Result { + suspend fun startAuthentication(): Result = initializeMutex.withLock { val attemptId = UUID.randomUUID().toString() _activeAuthAttemptId.update { attemptId } _approvedAuthAttemptId.update { null } _authState.update { PubkyAuthState.Authenticating } - return try { + try { runSuspendCatching { val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() } PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId) @@ -314,9 +322,13 @@ class PubkyRepo @Inject constructor( suspend fun completeAuthentication(): Result { val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) var shouldRevokeSessionOnFailure = false + var isInitializationLocked = false return try { val result = runSuspendCatching { waitForAuthApproval(attemptId) + initializeMutex.lock() + isInitializationLocked = true + ensureAuthAttemptActive(attemptId) withContext(ioDispatcher) { withContext(NonCancellable) { completeAuthPreservingExistingSession() @@ -337,22 +349,12 @@ class PubkyRepo @Inject constructor( if (result.isFailure) { revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure) - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } + clearAuthAttempt(attemptId) restoreAuthStateAfterAuthFlow() } result.onSuccess { pk -> - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } + clearAuthAttempt(attemptId) clearProfileIfIdentityChanged(pk) _publicKey.update { pk } _authState.update { PubkyAuthState.Authenticated } @@ -363,14 +365,20 @@ class PubkyRepo @Inject constructor( }.map { } } catch (e: CancellationException) { revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure) - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } + clearAuthAttempt(attemptId) restoreAuthStateAfterAuthFlow() throw e + } finally { + if (isInitializationLocked) initializeMutex.unlock() + } + } + + private fun clearAuthAttempt(attemptId: String) { + if (_activeAuthAttemptId.value == attemptId) { + _activeAuthAttemptId.update { null } + } + if (_approvedAuthAttemptId.value == attemptId) { + _approvedAuthAttemptId.update { null } } } @@ -604,9 +612,9 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): Result { + ): Result = initializeMutex.withLock { if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) { - return runSuspendCatching { + return@withLock runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) @@ -616,7 +624,7 @@ class PubkyRepo @Inject constructor( } var shouldRevokeSessionOnFailure = false - return try { + try { val result = runSuspendCatching { withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) @@ -1245,30 +1253,32 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result = withContext(NonCancellable + ioDispatcher) { - val hadPaykitState = settingsStore.data.first().hasPaykitState() - val endpointCleanupResult = removeBitkitPaymentEndpoints() - .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + initializeMutex.withLock { + val hadPaykitState = settingsStore.data.first().hasPaykitState() + val endpointCleanupResult = removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - val result = runSuspendCatching { - pubkyService.signOut() - }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } + val result = runSuspendCatching { + pubkyService.signOut() + }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } - if (result.isFailure) { - if (hadPaykitState) { - runSuspendCatching { - settingsStore.update { it.copy(publicPaykitCleanupPending = true) } - }.onFailure { - Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG) + if (result.isFailure) { + if (hadPaykitState) { + runSuspendCatching { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + }.onFailure { + Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG) + } } + return@withLock result } - return@withContext result - } - clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) - result + clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) + result + } } - suspend fun wipeLocalState() { + suspend fun wipeLocalState() = initializeMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { pubkyService.forgetSessionAccess() } }.onFailure { diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index cd7a13870d..83eb2870d7 100644 --- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch import to.bitkit.async.appScope import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.repositories.PubkyRepo import to.bitkit.utils.Logger @@ -48,24 +49,23 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( collectionScope.launch { pubkyRepo.awaitInitialization() - combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey -> - localFlagEnabled to publicKey + combine( + settingsStore.isPaykitEnabled, + pubkyRepo.publicKey, + pubkyRepo.backupStateVersion, + ) { localFlagEnabled, publicKey, _ -> + val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() } + .onFailure { Logger.warn("Failed to read saved Pubky identity", it, context = TAG) } + .getOrDefault(true) + val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled) + val hasSecretKey = isPaykitUiEnabled && publicKey != null && pubkyRepo.hasSecretKey() + val authEnabled = canHandlePubkyAuth(isPaykitUiEnabled, hasIdentity, hasSecretKey) + authEnabled to (isPaykitUiEnabled && !hasIdentity) } .distinctUntilChanged() - .collectLatest { (localFlagEnabled, publicKey) -> - val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled) - val hasIdentity = publicKey != null - val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey() - - setAliasEnabled( - aliasComponent, - canHandlePubkyAuth( - isPaykitUiEnabled = isPaykitUiEnabled, - hasIdentity = hasIdentity, - hasSecretKey = hasSecretKey, - ), - ) - setAliasEnabled(signupAliasComponent, isPaykitUiEnabled && !hasIdentity) + .collectLatest { (authEnabled, signupEnabled) -> + setAliasEnabled(aliasComponent, authEnabled) + setAliasEnabled(signupAliasComponent, signupEnabled) } } } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 9b97dfb0ac..037b5d21b3 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -343,6 +343,11 @@ class MainActivity : FragmentActivity() { intent.launchKey()?.let { outState.putString(KEY_CONSUMED_LAUNCH_INTENT, it) } } + override fun onResume() { + super.onResume() + appViewModel.onAppResumed() + } + override fun onStop() { super.onStop() if (!isChangingConfigurations) appViewModel.lockOnBackground() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8c4581b76b..86aa546d59 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -777,6 +777,7 @@ class AppViewModel @Inject constructor( .drop(1) .filter { it == ConnectivityState.CONNECTED } .collect { + pubkyRepo.restoreSessionIfNeeded() if (paykitPaymentRequestPollingJob?.isActive == true) { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null @@ -5704,6 +5705,12 @@ class AppViewModel @Inject constructor( fun checkTimedSheets() = timedSheetManager.onHomeScreenEntered() + fun onAppResumed() { + viewModelScope.launch { + if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.restoreSessionIfNeeded() + } + } + fun onHomeResumed() { checkTimedSheets() hwWalletRepo.onAppForegrounded() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 54901613d8..8a438d161e 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -466,6 +466,43 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { signOut() } } + @Test + fun `restoration cannot reuse a Ring session while cancelled completion revokes it`() = test { + var savedSession: String? = null + val completionInstalled = CompletableDeferred() + val revocationStarted = CompletableDeferred() + val finishRevocation = CompletableDeferred() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { savedSession } + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenAnswer { savedSession = "abandoned_session" } + whenever(pubkyService.currentPublicKey()).doSuspendableAnswer { + completionInstalled.complete(Unit) + awaitCancellation() + } + whenever(pubkyService.signOut()).doSuspendableAnswer { + revocationStarted.complete(Unit) + finishRevocation.await() + savedSession = null + } + whenever(pubkyService.importSession("abandoned_session")).thenReturn(VALID_SELF_KEY) + val request = startAuthForTesting() + approveAuthForTesting(request) + val completion = async { sut.completeAuthentication() } + completionInstalled.await() + completion.cancel() + revocationStarted.await() + sut.cancelAuthentication() + + val retry = async { sut.restoreSessionIfNeeded() } + finishRevocation.complete(Unit) + completion.join() + retry.await() + + assertNull(sut.publicKey.value) + assertFalse(sut.hasIdentity()) + verify(pubkyService, never()).importSession(any()) + } + @Test fun `completeAuthentication should keep session when canceled during profile load`() = test { val profileLoadStarted = CompletableDeferred() @@ -1477,14 +1514,101 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyStore, never()).reset() verifyBlocking(keychain, never()) { delete(any()) } + sut.clearSessionRestorationFailed() canRestore = true - sut.initialize() + sut.restoreSessionIfNeeded() assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) assertFalse(sut.sessionRestorationFailed.value) } + @Test + fun `restoration retry recovers service startup failure and skips an active session`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + var isOnline = false + whenever(pubkyService.initialize()).thenAnswer { + if (!isOnline) throw TestAppError("Offline") + Unit + } + whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) + val repo = createSut() + repo.awaitInitialization() + assertNull(repo.publicKey.value) + + isOnline = true + repo.restoreSessionIfNeeded() + assertEquals(VALID_SELF_KEY, repo.publicKey.value) + clearInvocations(pubkyService) + + repo.restoreSessionIfNeeded() + verify(pubkyService, never()).importSession(any()) + verify(pubkyService, never()).signIn(any()) + } + + @Test + fun `unreadable credentials preserve cached profile and remain retryable`() = test { + var readable = false + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { + if (!readable) throw TestAppError("Keychain unavailable") + "saved_session" + } + clearInvocations(pubkyStore) + + sut.initialize() + + assertTrue(sut.sessionRestorationFailed.value) + verify(pubkyStore, never()).reset() + readable = true + whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) + sut.restoreSessionIfNeeded() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `restoration retry skips an identity awaiting Ring approval`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(pubkyService.startAuth()).thenReturn("pubkyauth://request") + sut.startAuthentication().getOrThrow() + clearInvocations(pubkyService) + + sut.restoreSessionIfNeeded() + + verify(pubkyService, never()).importSession(any()) + verify(pubkyService, never()).signIn(any()) + } + + @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") + whenever(keychain.loadString(any())).thenAnswer { credentials[it.getArgument(0)] } + whenever(keychain.delete(any())).thenAnswer { + credentials.remove(it.getArgument(0)) + Unit + } + val restoreStarted = CompletableDeferred() + val finishRestore = CompletableDeferred() + whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { + restoreStarted.complete(Unit) + finishRestore.await() + VALID_SELF_KEY + } + val restore = async { sut.restoreSessionIfNeeded() } + restoreStarted.await() + val wipe = async { sut.wipeLocalState() } + assertFalse(wipe.isCompleted) + finishRestore.complete(Unit) + restore.await() + wipe.await() + clearInvocations(pubkyService) + + sut.restoreSessionIfNeeded() + + assertNull(sut.publicKey.value) + assertFalse(sut.hasIdentity()) + verify(pubkyService, never()).importSession(any()) + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index 0dada70974..7fbafe32c8 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageManager import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test @@ -42,13 +43,16 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { private val settingsStore: SettingsStore = mock() private val isPaykitEnabled = MutableStateFlow(false) private val publicKey = MutableStateFlow(null) + private val backupStateVersion = MutableStateFlow(0L) @Before - fun setUp() { + fun setUp() = runBlocking { whenever(context.packageName).thenReturn(PACKAGE_NAME) whenever(context.packageManager).thenReturn(packageManager) whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) whenever(pubkyRepo.publicKey).thenReturn(publicKey) + whenever(pubkyRepo.backupStateVersion).thenReturn(backupStateVersion) + whenever(pubkyRepo.hasIdentity()).thenAnswer { publicKey.value != null } } @Test @@ -144,6 +148,32 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { verifyComponentStates(authEnabled = false, signupEnabled = true) } + @Test + fun `saved identity suppresses signup during restoration failure until explicitly removed`() = test { + isPaykitEnabled.value = true + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + verifyComponentStates(authEnabled = false, signupEnabled = false) + clearInvocations(packageManager) + + whenever(pubkyRepo.hasIdentity()).thenReturn(false) + backupStateVersion.value += 1 + runCurrent() + + verifyComponentStates(authEnabled = false, signupEnabled = true) + } + + @Test + fun `unreadable credentials do not advertise signup`() = test { + isPaykitEnabled.value = true + whenever(pubkyRepo.hasIdentity()).thenAnswer { throw IllegalStateException("Keychain unavailable") } + createSut().start(backgroundScope) + runCurrent() + + verifyComponentStates(authEnabled = false, signupEnabled = false) + } + @Test fun `handler is disabled when the Paykit UI is turned off`() = test { isPaykitEnabled.value = true diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 148c33909b..f7de1d0098 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -522,6 +522,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onAppForegrounded() } + @Test + fun `app resume and connectivity restoration retry the saved Pubky session`() = test { + clearInvocations(pubkyRepo) + connectivityState.value = ConnectivityState.DISCONNECTED + sut.onAppResumed() + verify(pubkyRepo, never()).restoreSessionIfNeeded() + + connectivityState.value = ConnectivityState.CONNECTED + verify(pubkyRepo).restoreSessionIfNeeded() + clearInvocations(pubkyRepo) + + sut.onAppResumed() + verify(pubkyRepo).restoreSessionIfNeeded() + } + @Test fun `critical update is required for a newer critical build`() = test { whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) diff --git a/changelog.d/next/1339.fixed.md b/changelog.d/next/1339.fixed.md index f2384e1109..c15492c56b 100644 --- a/changelog.d/next/1339.fixed.md +++ b/changelog.d/next/1339.fixed.md @@ -1 +1 @@ -Fixed Paykit recovery after device clock changes to preserve contacts and improve retry and billing reminder timing. +Fixed Paykit recovery after connection failures and device clock changes to preserve profile data and contacts, restore saved sessions, and improve billing reminder timing. diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index be49f593b4..0c8127d409 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,3 +24,15 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. + +## Connection loss and saved identity recovery + +Network fault injection is not provided by the journey capability table. Use a disposable wallet with a saved local identity, then repeat with a Ring-authorized identity. + +1. Record the profile name, public key, contacts, receiving address, and wallet balance while online. +2. Disable both Wi-Fi and mobile data on the test device, force-stop Bitkit, and reopen it. Wait for session restoration to fail. The cached name must remain, and the app must not advertise Pubky signup for this existing identity. +3. Re-enable connectivity while leaving Bitkit open. Verify that the same identity and contact list recover without scanning Ring again, signing out, or restarting the app when the saved grant is still valid. For an expired or revoked grant, reauthorization remains required. +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. +5. Start a Ring authorization while recovery is pending. Verify that automatic restoration does not replace that attempt. Explicit sign-out or wallet reset must not be undone by a pending restoration. + +Both platforms retry automatically on connectivity restoration and app resume. A valid saved session must recover without a new authorization; expired or revoked grants still require Ring. From d93a2cb7c9994a5b88c7830c7bfb2fb44a9245b8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 16:12:56 +0100 Subject: [PATCH 4/6] fix: preserve restored paykit identity --- .../java/to/bitkit/repositories/PubkyRepo.kt | 21 +++++++++--- .../services/PubkyAuthHandlerRegistrar.kt | 3 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 34 +++++++++++++++++++ .../services/PubkyAuthHandlerRegistrarTest.kt | 18 ++++++++-- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index c839ce9357..47f0928768 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -143,6 +143,9 @@ class PubkyRepo @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + private val _identityRefreshVersion = MutableStateFlow(0L) + val identityRefreshVersion: StateFlow = _identityRefreshVersion.asStateFlow() + val isAuthenticated: StateFlow = _publicKey.map { it != null } .stateIn(scope, SharingStarted.Eagerly, false) @@ -187,7 +190,9 @@ class PubkyRepo @Inject constructor( initializeMutex.withLock { if (_publicKey.value != null || _authState.value != PubkyAuthState.Idle) return@withLock runSuspendCatching { - if (hasIdentity()) initializeSession() + val hasIdentity = hasIdentity() + _identityRefreshVersion.update { it + 1 } + if (hasIdentity) initializeSession() }.onFailure { Logger.warn("Failed to retry paykit session restoration", it, context = TAG) } } } @@ -629,9 +634,17 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) { - pubkyService.signIn(storedSecretKeyHex) - pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + val activePublicKey = _publicKey.value + val localSecretKeyHex = if (activePublicKey == null) { + storedSecretKeyHex + } else { + managedSecretKeyFor(activePublicKey) + } + val publicKeyZ32 = if (!localSecretKeyHex.isNullOrEmpty()) { + pubkyService.signIn(localSecretKeyHex) + pubkyService.publicKeyFromSecret(localSecretKeyHex).ensurePubkyPrefix() + } else if (activePublicKey != null) { + activePublicKey } else { val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index 83eb2870d7..ba19d236f6 100644 --- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -53,7 +53,8 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( settingsStore.isPaykitEnabled, pubkyRepo.publicKey, pubkyRepo.backupStateVersion, - ) { localFlagEnabled, publicKey, _ -> + pubkyRepo.identityRefreshVersion, + ) { 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/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 8a438d161e..0865269a2c 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -887,6 +887,23 @@ class PubkyRepoTest : BaseUnitTest() { httpClient.close() } + @Test + fun `createIdentity cannot replace a restored Ring identity`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("ring-session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("") + whenever(pubkyService.importSession("ring-session")).thenReturn(VALID_CONTACT_KEY_A) + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + stubSignupKeys() + + sut.initialize() + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertTrue(result.isSuccess) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + } + @Test fun `createIdentity should preserve signup session when pending profile publication fails`() = test { val registeredSession = mock() @@ -1565,6 +1582,23 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(VALID_SELF_KEY, sut.publicKey.value) } + @Test + fun `restoration retry publishes a readable empty identity check`() = test { + var readable = false + whenever(keychain.loadString(any())).thenAnswer { + if (!readable) throw TestAppError("Keychain unavailable") + null + } + sut.initialize() + val previousVersion = sut.identityRefreshVersion.value + + readable = true + sut.restoreSessionIfNeeded() + + assertEquals(previousVersion + 1, sut.identityRefreshVersion.value) + assertFalse(sut.hasIdentity()) + } + @Test fun `restoration retry skips an identity awaiting Ring approval`() = test { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index 7fbafe32c8..8e5f659d6d 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -44,6 +44,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { private val isPaykitEnabled = MutableStateFlow(false) private val publicKey = MutableStateFlow(null) private val backupStateVersion = MutableStateFlow(0L) + private val identityRefreshVersion = MutableStateFlow(0L) @Before fun setUp() = runBlocking { @@ -52,6 +53,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(pubkyRepo.backupStateVersion).thenReturn(backupStateVersion) + whenever(pubkyRepo.identityRefreshVersion).thenReturn(identityRefreshVersion) whenever(pubkyRepo.hasIdentity()).thenAnswer { publicKey.value != null } } @@ -165,13 +167,23 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { } @Test - fun `unreadable credentials do not advertise signup`() = test { + fun `signup becomes available when unreadable credentials recover empty`() = test { isPaykitEnabled.value = true - whenever(pubkyRepo.hasIdentity()).thenAnswer { throw IllegalStateException("Keychain unavailable") } + var readable = false + whenever(pubkyRepo.hasIdentity()).thenAnswer { + check(readable) { "Keychain unavailable" } + false + } createSut().start(backgroundScope) runCurrent() - verifyComponentStates(authEnabled = false, signupEnabled = false) + clearInvocations(packageManager) + + readable = true + identityRefreshVersion.value += 1 + runCurrent() + + verifyComponentStates(authEnabled = false, signupEnabled = true) } @Test From 6e37e65cfd4231532219d7a6353bd77a1f845047 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 18:22:15 +0100 Subject: [PATCH 5/6] fix: keep paykit retry failures silent --- .../java/to/bitkit/repositories/PubkyRepo.kt | 33 +++++++----- .../to/bitkit/repositories/PubkyRepoTest.kt | 54 ++++++++++++++++++- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index da5f43204c..b79d5666be 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -185,28 +185,39 @@ class PubkyRepo @Inject constructor( } suspend fun initialize() = withContext(ioDispatcher) { - initializeMutex.withLock { initializeSession() } + val restored = initializeMutex.withLock { initializeSession() } + if (restored) { + loadProfile() + loadContacts() + } } suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { awaitInitialization() - initializeMutex.withLock { - if (_publicKey.value != null || _authState.value != PubkyAuthState.Idle) return@withLock + val restored = initializeMutex.withLock { + if (_publicKey.value != null || _authState.value != PubkyAuthState.Idle) return@withLock false runSuspendCatching { val hasIdentity = hasIdentity() _identityRefreshVersion.update { it + 1 } - if (hasIdentity) initializeSession() + hasIdentity && initializeSession(notifyFailure = false) }.onFailure { Logger.warn("Failed to retry paykit session restoration", it, context = TAG) } + .getOrDefault(false) + } + if (restored) { + loadProfile() + loadContacts() } } - private suspend fun initializeSession() { + private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean { runSuspendCatching { ensureServiceInitialized() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) - if (it.isPaykitIdentityError() && hasSavedSession()) _sessionRestorationFailed.update { true } - }.getOrNull() ?: return + if (notifyFailure && it.isPaykitIdentityError() && hasSavedSession()) { + _sessionRestorationFailed.update { true } + } + }.getOrNull() ?: return false _sessionRestorationFailed.update { false } val result = runSuspendCatching { @@ -233,15 +244,11 @@ class PubkyRepo @Inject constructor( } is InitResult.RestorationFailed -> { clearAuthenticatedState(clearCachedProfile = false) - _sessionRestorationFailed.update { true } + if (notifyFailure) _sessionRestorationFailed.update { true } } } initializationReady.complete(Unit) - - if (result is InitResult.Restored) { - loadProfile() - loadContacts() - } + return result is InitResult.Restored } private fun hasSavedSession(): Boolean = runCatching { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index a9da5f548f..36aeb1c904 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1423,10 +1423,14 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `initialize should flag session restoration failure when service startup fails with identity error`() = test { + var serviceAvailable = false whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") whenever(pubkyService.initialize()).thenAnswer { - throw AppError(PaykitException.Identity("identity_error", "Missing capabilities")) + if (!serviceAvailable) { + throw AppError(PaykitException.Identity("identity_error", "Missing capabilities")) + } } + whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) val repo = createSut() repo.awaitInitialization() @@ -1436,6 +1440,14 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, never()).importSession(any()) verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + + repo.clearSessionRestorationFailed() + repo.restoreSessionIfNeeded() + assertFalse(repo.sessionRestorationFailed.value) + + serviceAvailable = true + repo.restoreSessionIfNeeded() + assertEquals(VALID_SELF_KEY, repo.publicKey.value) } @Test @@ -1556,6 +1568,9 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, never()) { delete(any()) } sut.clearSessionRestorationFailed() + sut.restoreSessionIfNeeded() + assertFalse(sut.sessionRestorationFailed.value) + canRestore = true sut.restoreSessionIfNeeded() @@ -1667,6 +1682,43 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, never()).importSession(any()) } + @Test + fun `wipe completes while restoration profile loading remains in flight`() = test { + sut.awaitInitialization() + val credentials = mutableMapOf(Keychain.Key.PAYKIT_SESSION.name to "saved_session") + whenever(keychain.loadString(any())).thenAnswer { credentials[it.getArgument(0)] } + whenever(keychain.delete(any())).thenAnswer { + credentials.remove(it.getArgument(0)) + Unit + } + whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) + val profileLoadStarted = CompletableDeferred() + val finishProfileLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + profileLoadStarted.complete(Unit) + finishProfileLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + } + clearInvocations(pubkyStore) + val restore = async { sut.restoreSessionIfNeeded() } + profileLoadStarted.await() + + try { + val wipe = async { sut.wipeLocalState() } + wipe.await() + + assertFalse(restore.isCompleted) + assertNull(sut.publicKey.value) + } finally { + finishProfileLoad.complete(Unit) + } + restore.await() + + assertNull(sut.profile.value) + assertTrue(sut.contacts.value.isEmpty()) + verify(pubkyStore).reset() + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" From 0d32249f583c525c9254ab34a47c230b1ea0ab32 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 19:07:18 +0100 Subject: [PATCH 6/6] fix: preserve paykit restoration failure signal --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 15 +++++++++++---- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 3 +-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index b79d5666be..0283cc1ceb 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -219,7 +219,7 @@ class PubkyRepo @Inject constructor( } }.getOrNull() ?: return false - _sessionRestorationFailed.update { false } + if (notifyFailure) _sessionRestorationFailed.update { false } val result = runSuspendCatching { val savedSessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) @@ -238,12 +238,16 @@ class PubkyRepo @Inject constructor( Logger.debug("Found no saved paykit session", context = TAG) } is InitResult.Restored -> { + _sessionRestorationFailed.update { false } _publicKey.update { result.publicKey } _authState.update { PubkyAuthState.Authenticated } Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) } is InitResult.RestorationFailed -> { - clearAuthenticatedState(clearCachedProfile = false) + clearAuthenticatedState( + clearCachedProfile = false, + clearRestorationFailure = notifyFailure, + ) if (notifyFailure) _sessionRestorationFailed.update { true } } } @@ -1466,7 +1470,10 @@ class PubkyRepo @Inject constructor( _backupStateVersion.update { it + 1 } } - private suspend fun clearAuthenticatedState(clearCachedProfile: Boolean = true) = withContext(ioDispatcher) { + private suspend fun clearAuthenticatedState( + clearCachedProfile: Boolean = true, + clearRestorationFailure: Boolean = true, + ) = withContext(ioDispatcher) { if (clearCachedProfile) { evictPubkyImages() runSuspendCatching { pubkyStore.reset() } @@ -1477,7 +1484,7 @@ class PubkyRepo @Inject constructor( _contactsLoadVersion.update { 0L } _contactsLoadCompletionVersion.update { 0L } clearPendingImport() - _sessionRestorationFailed.update { false } + if (clearRestorationFailure) _sessionRestorationFailed.update { false } _authState.update { PubkyAuthState.Idle } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 36aeb1c904..29e35cf179 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1567,9 +1567,8 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyStore, never()).reset() verifyBlocking(keychain, never()) { delete(any()) } - sut.clearSessionRestorationFailed() sut.restoreSessionIfNeeded() - assertFalse(sut.sessionRestorationFailed.value) + assertTrue(sut.sessionRestorationFailed.value) canRestore = true sut.restoreSessionIfNeeded()