Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ab59838
fix: stop automatic profile deletion replay
ben-kaufman Oct 24, 2026
99b446a
docs: associate deletion fix with PR 1688
ben-kaufman Oct 24, 2026
5fe0bf6
fix: integrate current restore recovery parent
ben-kaufman Oct 28, 2026
2aac53e
Merge current identity recovery into profile session recovery
ben-kaufman Oct 29, 2026
ff5aa32
Merge restored identity guards into profile deletion recovery
ben-kaufman Oct 30, 2026
3001faf
test: report Pubky alias state on routing failures
ben-kaufman Oct 30, 2026
e3384ed
chore: merge validated restore identity parent
ben-kaufman Oct 31, 2026
55afaad
Merge queued allowance admission fix
ben-kaufman Nov 1, 2026
3f4b19f
Merge stable allowance activation into profile recovery
ben-kaufman Nov 1, 2026
2621f81
Merge guarded identity and allowance recovery parent
ben-kaufman Nov 1, 2026
9c145d9
Merge current allowance resume fix into recovery branch
ben-kaufman Nov 1, 2026
2efaff0
merge: include final identity recovery parent
ben-kaufman Nov 1, 2026
3d8a328
merge: integrate current identity recovery
ben-kaufman Nov 3, 2026
916c6d5
fix: compose original-wallet VSS ownership with profile deletion
ben-kaufman Nov 4, 2026
5cf930f
merge: compose queued backup status
ben-kaufman Nov 4, 2026
a8c3fd3
merge: compose admitted backup status
ben-kaufman Nov 4, 2026
dc9a981
fix: preserve explicit profile deletion recovery
ben-kaufman Nov 11, 2026
8a96806
fix: preserve profile ownership during recovery
ben-kaufman Nov 11, 2026
8d10218
fix: retain recovery guards in the composed parent
ben-kaufman Nov 12, 2026
5f0a8ad
fix: preserve recovery with spark balance and result updates
ben-kaufman Nov 12, 2026
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
16 changes: 3 additions & 13 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1240,16 +1240,6 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun deleteProfileWithSessionRetry(): Result<Unit> = withContext(ioDispatcher) {
val initialResult = deleteProfile()
if (initialResult.isSuccess) return@withContext initialResult

val refreshedSession = refreshSessionIfPossible().getOrDefault(false)
if (!refreshedSession) return@withContext initialResult

deleteProfile()
}

suspend fun deleteProfile(): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
Expand Down Expand Up @@ -1283,10 +1273,10 @@ class PubkyRepo @Inject constructor(
val publicKey = _publicKey.value ?: return
val known = ownProfileRevision
val revision = known ?: pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision ?: return
runSuspendCatching { withPubkyAppScope { pubkyService.deletePubkyProfile(revision, publicKey) } }.getOrElse {
runSuspendCatching { pubkyService.deletePubkyProfile(revision, publicKey) }.getOrElse {
if (known == null || !it.isConcurrentUpdate()) throw it
val current = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision
if (current != null) withPubkyAppScope { pubkyService.deletePubkyProfile(current, publicKey) }
if (current != null) pubkyService.deletePubkyProfile(current, publicKey)
}
ownProfileRevision = null
}
Expand Down Expand Up @@ -2319,7 +2309,7 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun refreshSessionIfPossible(): Result<Boolean> = runSuspendCatching {
private suspend fun refreshSessionIfPossible(): Result<Boolean> = runSuspendCatching {
withContext(ioDispatcher) {
val storedSecretKeyHex = activeSecretKeyHex() ?: return@withContext false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ class EditProfileViewModel @Inject constructor(
try {
privatePaykitRepo.beginProfileDeletion()
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true)
val result = pubkyRepo.deleteProfileWithSessionRetry()
val result = pubkyRepo.deleteProfile()
if (result.isSuccess) {
privatePaykitRepo.closeAndClear()
ToastEventBus.send(
Expand Down
101 changes: 53 additions & 48 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,26 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(pubkyService) { deletePubkyProfile("revision-Test", VALID_SELF_KEY) }
}

@Test
fun `pubky app deletion does not renew the session or replay an identity error`() = test {
val secretKey = "local_secret"
authenticateForTesting(publicKey = VALID_SELF_KEY)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val identityError = PaykitException.Identity("identity_error", "Missing capabilities")
whenever(pubkyService.deletePubkyProfile("revision-Test", VALID_SELF_KEY))
.thenAnswer { throw identityError }
.thenReturn(Unit)

val result = sut.deleteProfile()

assertSame(identityError, result.exceptionOrNull())
verify(pubkyService).deletePubkyProfile("revision-Test", VALID_SELF_KEY)
verify(pubkyService, never()).signIn(any())
verify(pubkyService, never()).deletePaykitProfile()
verify(pubkyService, never()).signOut()
}

@Test
fun `deleteProfile is not followed by a profile copy write from a load under way`() = test {
authenticateForTesting(publicKey = VALID_SELF_KEY)
Expand Down Expand Up @@ -2121,36 +2141,46 @@ class PubkyRepoTest : BaseUnitTest() {
}

@Test
fun `deleteProfileWithSessionRetry should refresh session and retry delete`() = test {
val expiredSession = "expired_session"
val newSession = "new_session"
fun `profile deletion does not replay after an uncertain response`() = test {
val secretKey = "local_secret"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession, newSession)
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = "test_session")
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") }.thenReturn(Unit)
whenever(pubkyService.signIn(secretKey)).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val uncertainResponse = TestAppError("Connection closed after profile deletion")
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw uncertainResponse }.thenReturn(Unit)

val result = sut.deleteProfileWithSessionRetry()
val result = sut.deleteProfile()

assertTrue(result.isSuccess)
verifyBlocking(pubkyService, times(2)) { deletePaykitProfile() }
assertSame(uncertainResponse, result.exceptionOrNull())
verify(pubkyService).deletePaykitProfile()
verify(pubkyService, never()).signIn(any())
verify(pubkyService, never()).signOut()
}

@Test
fun `deleteProfileWithSessionRetry should return failure when session cannot refresh`() = test {
val expiredSession = "expired_session"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") }

val result = sut.deleteProfileWithSessionRetry()
fun `failed profile deletion does not restore an identity after sign out`() = test {
val secretKey = "local_secret"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = "test_session")
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val deletionStarted = CompletableDeferred<Unit>()
val finishDeletion = CompletableDeferred<Unit>()
val uncertainResponse = TestAppError("Connection closed after profile deletion")
whenever(pubkyService.deletePaykitProfile()).doSuspendableAnswer {
deletionStarted.complete(Unit)
withContext(NonCancellable) { finishDeletion.await() }
throw uncertainResponse
}
val deletion = async { sut.deleteProfile() }
deletionStarted.await()
sut.signOut().getOrThrow()
finishDeletion.complete(Unit)

assertTrue(result.isFailure)
verifyBlocking(pubkyService) { deletePaykitProfile() }
verifyBlocking(pubkyService, never()) { signIn(any()) }
assertSame(uncertainResponse, deletion.await().exceptionOrNull())
assertNull(sut.publicKey.value)
assertFalse(sut.isAuthenticated.value)
verify(pubkyService, never()).signIn(any())
verify(pubkyService).deletePaykitProfile()
}

@Test
Expand Down Expand Up @@ -4420,31 +4450,6 @@ class PubkyRepoTest : BaseUnitTest() {
assertFalse(profileSetupPending.value)
}

@Test
fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test {
val secretKey = "local_secret"
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.signIn(secretKey)).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))

val result = sut.refreshSessionIfPossible()

assertEquals(true, result.getOrNull())
assertEquals(VALID_SELF_KEY, sut.publicKey.value)
assertTrue(sut.isAuthenticated.value)
}

@Test
fun `refreshSessionIfPossible should return false when local secret key is missing`() = test {
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)

val result = sut.refreshSessionIfPossible()

assertEquals(false, result.getOrNull())
assertNull(sut.publicKey.value)
assertFalse(sut.isAuthenticated.value)
}

@Test
fun `restoreSessionBackupState should derive local secret key for local seed backups`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic")
Expand Down Expand Up @@ -5348,15 +5353,15 @@ class PubkyRepoTest : BaseUnitTest() {
}

@Test
fun `a contact edit started before the session of its identity is refreshed still saves`() = test {
fun `a contact edit started before the session of its identity is restored still saves`() = test {
val store = stubGatedPubkyStore()
authenticateForTesting(publicKey = VALID_SELF_KEY)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret")
whenever(pubkyService.signIn("local_secret")).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret("local_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val signIn = checkNotNull(sut.currentSignIn())

assertEquals(true, sut.refreshSessionIfPossible().getOrNull())
sut.initialize().getOrThrow()
val result = sut.updateContact(signIn, VALID_CONTACT_KEY_A, "Alice", "", null, emptyList(), listOf("Friend"))

assertTrue(result.isSuccess)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).forgetUnrestoredIdentity()
verify(pubkyRepo, never()).deleteProfileWithSessionRetry()
verify(pubkyRepo, never()).deleteProfile()
verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), anyOrNull(), any())
assertFalse(sut.uiState.value.showDeleteDialog)

Expand Down Expand Up @@ -115,7 +115,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
val sut = createSut()
advanceUntilIdle()
val result = CompletableDeferred<Result<Unit>>()
whenever(pubkyRepo.deleteProfileWithSessionRetry()).doSuspendableAnswer { result.await() }
whenever(pubkyRepo.deleteProfile()).doSuspendableAnswer { result.await() }

sut.showDeleteConfirmation()
sut.deleteProfile()
Expand All @@ -131,7 +131,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.disconnectProfile()
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).deleteProfileWithSessionRetry()
verify(pubkyRepo, times(1)).deleteProfile()
verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), anyOrNull(), any())
verify(pubkyRepo, never()).signOut()
assertFalse(sut.uiState.value.showDeleteDialog)
Expand Down Expand Up @@ -165,7 +165,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), any(), anyOrNull(), any())
verify(pubkyRepo, never()).deleteProfileWithSessionRetry()
verify(pubkyRepo, never()).deleteProfile()
verify(pubkyRepo, never()).forgetUnrestoredIdentity()
assertFalse(sut.uiState.value.showDeleteDialog)

Expand All @@ -187,7 +187,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `deleteProfile should emit success when repository delete succeeds`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))

val sut = createSut()
advanceUntilIdle()
Expand All @@ -201,14 +201,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `profile created in Bitkit keeps delete copy`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
val sut = createSut()
advanceUntilIdle()

Expand All @@ -222,7 +222,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `ring identity is unlinked with the same cleanup`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
val sut = createSut(isRingIdentity = true)
advanceUntilIdle()

Expand All @@ -238,7 +238,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
verify(context, never()).getString(R.string.profile__delete_success)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}
Expand All @@ -260,7 +260,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `retryDeleteProfile should emit success when repository delete succeeds`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))

val sut = createSut()
advanceUntilIdle()
Expand All @@ -274,14 +274,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `deleteProfile should show retry dialog when delete still fails`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)

Expand All @@ -299,7 +299,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `disconnectProfile should emit disconnect success`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)
whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit))
Expand Down Expand Up @@ -358,7 +358,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
val sut = createSut()
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
.thenReturn(Result.failure(TestAppError("cleanup failed")))
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
advanceUntilIdle()

sut.effects.test {
Expand All @@ -371,14 +371,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.isDeleting)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `dismissDeleteFailureDialog should hide retry dialog`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)

Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1688.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevent uncertain profile deletion failures from automatically repeating contact cleanup and deletion.
17 changes: 17 additions & 0 deletions journeys/profile/delete-profile-uncertain-response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<journey name="profile deletion uncertain response">
<description>
Requires a disposable test identity and a controlled homeserver proxy; never use a personal profile.
Configure the proxy to accept one profile deletion but lose its acknowledgement, retaining a redacted
request counter. This is a fault-injection fixture requirement, not evidence that this journey has run.
Normal SDK recovery may retry an individual request only after a confirmed authentication rejection;
the app must not restart contact cleanup and profile deletion because a request has an uncertain outcome.
</description>
<actions>
<action>Open Edit Profile and confirm deletion</action>
<action>Drop the acknowledgement after the server accepts the profile deletion</action>
<action>Verify failure options are shown and no success notification or onboarding navigation occurs</action>
<action>Wait through the network timeout and verify the app starts no second contact cleanup, profile deletion or sign-in</action>
<action>Cancel the failure dialog and verify no mutation is retried</action>
<action>Restart the app without choosing Retry and verify it does not automatically repeat the deletion</action>
</actions>
</journey>
Loading