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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 9 additions & 40 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1093,42 +1093,27 @@ class PubkyRepo @Inject constructor(
}
clearSessionContactProfiles()
deleteAllContacts()
runSuspendCatching {
pubkyService.deletePaykitProfile()
}.getOrElse {
if (!it.isMissingPubkyData()) {
throw it
}
Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG)
}
pubkyService.deletePaykitProfile()
profileWriteGeneration.incrementAndGet()
}
settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) }
signOut().getOrThrow()
}

private suspend fun deleteAllContacts() {
val records = runSuspendCatching {
pubkyService.contactRecords()
}.getOrElse {
if (!it.isMissingPubkyData()) throw it
emptyList()
}
runSuspendCatching {
val removed = pubkyService.removeContacts(records.map { it.publicKey })
if (removed.size != records.size) {
Logger.warn("Retained contacts that could not be removed during profile deletion", context = TAG)
}
}.onFailure {
Logger.warn("Failed to delete contacts", it, context = TAG)
}
pubkyStore.update { it.copy(contactProfileOverrides = emptyMap()) }
val records = pubkyService.contactRecords()
if (records.isEmpty()) return
val keys = records.map { it.publicKey.ensurePubkyPrefix() }.toSet()
val removedKeys = pubkyService.removeContacts(records.map { it.publicKey })
.map { it.publicKey.ensurePubkyPrefix() }.toSet().intersect(keys)
pubkyStore.update { it.copy(contactProfileOverrides = it.contactProfileOverrides - removedKeys) }
notifyBackupStateChanged()
synchronized(contactsLock) {
cancelContactScreenLookups()
updateContacts { emptyList() }
updateContacts { current -> current.filterNot { it.publicKey in removedKeys } }
}
markContactsLoaded()
if (removedKeys != keys) throw PrivatePaykitError.PrivateUnavailable

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MEDIUM] An active subscription with a saved contact now blocks profile deletion permanently, with no explanation

Trigger: the user has a saved contact with an ACTIVE_RECURRING payment request that has not ended (either payer or payee role), and confirms Delete Profile.

Mechanism: PaykitSdkService.removeContacts (PaykitSdkService.kt:807-815) silently leaves out subscribed counterparties before calling removeContactsAndBlockPeers. This is a deliberate retention policy: PaykitSdkServiceTest active subscription prevents deletion until it ends asserts it returns an empty list. In this PR, deleteAllContacts compares the returned keys to every record and throws PrivatePaykitError.PrivateUnavailable when they differ. By the time it throws, attemptDeleteProfile has already withdrawn the private payment links (removePublishedEndpointsForCleanup), and the SDK has removed and blocked every other saved contact. Nothing checks for active subscriptions beforehand, and the error carries no subscription reason. The single-contact delete path does report one (PubkyContactError.ActiveSubscription).

Consequence: this path shows the generic dialog "We couldn't delete your profile data. Retry, or disconnect…". Every Retry withdraws the links again and fails the same way until the subscription is cancelled or ends. The user is never told why, and is left with a profile that still exists, its other contacts already removed and blocked, and its links withdrawn. Disconnect is the only way out, and it leaves the profile published.

Expected behavior: contacts kept on purpose because of a subscription should be treated differently from unknown or failed removals. Check for active subscriptions before any destructive step and show an actionable reason (as the single-contact delete does), or exclude these contacts from the incomplete-cleanup stop.

Evidence basis: source analysis at 2daec90 and paykit-rs f384694 (remove_contacts_and_block_peers doc: a skipped record does not stop the others from being removed). Not reproduced on a device. An earlier bot review called this stop intended. This finding is about the consequences above (non-actionable message, a Retry that always fails, destructive steps before the check), not about the stop itself.

Logger.info("Deleted all contacts", context = TAG)
}

Expand Down Expand Up @@ -2262,21 +2247,5 @@ class PubkyRepo @Inject constructor(

private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey)

private fun Throwable.isMissingPubkyData(): Boolean {
val fullMessage = buildErrorMessage()
return fullMessage.contains("404") ||
fullMessage.contains("not found", ignoreCase = true) ||
fullMessage.contains("missing", ignoreCase = true)
}

private fun Throwable.buildErrorMessage(): String =
buildString {
append(message.orEmpty())
cause?.message?.takeIf { it.isNotBlank() }?.let {
append(" ")
append(it)
}
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ class EditProfileViewModel @Inject constructor(
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG).getOrThrow()
pubkyRepo.signOut().getOrThrow()
}
privatePaykitRepo.closeAndClear()
privatePaykitRepo.closeAndClear().getOrThrow()
}
}
if (result.isSuccess) {
Expand Down Expand Up @@ -272,10 +272,20 @@ class EditProfileViewModel @Inject constructor(
}
try {
privatePaykitRepo.beginProfileDeletion()
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true)
val result = pubkyRepo.deleteProfile()
val result = runSuspendCatching {
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When deletion stops after this deferred withdrawal succeeded, private endpoints stay withdrawn until the next foreground or network event. With deferAppSync = true, a successful withdrawal leaves cleanupPending = true (PrivatePaykitRepo.kt:508-513). scheduleContactPreparation returns early while it's set. endProfileDeletion() only flips the in-progress flag, and AppViewModel's follow-up synchronizePaykitContacts → scheduleSavedContactPreparation also does nothing. Only retryPendingEndpointRemoval clears the flag, and it runs on foreground (ContentView.kt:319 → AppViewModel.kt:871), network restore, channel events or a USDT availability change.

So after Cancel on the failure dialog the user is signed in, but their contacts can't pay them privately until the app is backgrounded and reopened. Master had the same deferred flag only on the delete-fails branch. This PR adds three stop cases that now reach it: a partial contact removal, a contact-read error, and a 404 from the profile delete. iOS clears the flag on any successful withdrawal (PubkyProfileManager.swift:1498).

Fix: keep the flag (the crash-recovery test interrupted profile deletion retries registry sync from persisted cleanup state depends on it) and reconcile on the stop path instead, in this function's finally:

withContext(NonCancellable) {
    privatePaykitRepo.endProfileDeletion()
    if (_uiState.value.showDeleteFailureDialog) {
        privatePaykitRepo.retryPendingEndpointRemoval(pubkyRepo.contacts.value.map { it.publicKey })
        privatePaykitRepo.refreshKnownSavedContactEndpoints(TAG)
    }
}

In production, route this through contactPaymentSettingsRepo.reconcilePendingEndpoints or an equivalent entry point, as the foreground path does.

LOW.

Reproduction

EditProfileViewModelTest > stopped deletion reconciles withdrawn private endpoints fails at 2daec90 and passes with the fix (20/20):

Wanted but not invoked:
privatePaykitRepo.retryPendingEndpointRemoval(...)

PrivatePaykitRepoTest > stopped profile deletion keeps private endpoints withdrawn until pending cleanup is retried passes at head by design. It shows the stuck state against the real repo: no ensureLinkWithPeer after preparation is rescheduled, with the flag still set. A retry plus a refresh republishes.

./gradlew testDevDebugUnitTest --tests 'to.bitkit.ui.screens.profile.EditProfileViewModelTest' --tests 'to.bitkit.repositories.PrivatePaykitRepoTest'

diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
index 5e0aa48fe..cb5ef2438 100644
--- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
@@ -370,6 +370,38 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
         sut.closeAndClear()
     }
 
+    @Test
+    fun `stopped profile deletion keeps private endpoints withdrawn until pending cleanup is retried`() = test {
+        settingsData.update {
+            SettingsData(
+                sharesPrivatePaykitEndpoints = true,
+                publicPaykitLightningEnabled = false,
+                publicPaykitUsdtEnabled = false
+            )
+        }
+        cacheData.update { it.copy(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) }
+        sut = createSut()
+
+        sut.beginProfileDeletion()
+        sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true).getOrThrow()
+        sut.endProfileDeletion()
+        sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow()
+        runCurrent()
+        sut.awaitContactPreparation()
+
+        assertTrue(cacheData.value.cleanupPending)
+        verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY)
+
+        sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow()
+        sut.refreshKnownSavedContactEndpoints("profile deletion stopped").getOrThrow()
+        runCurrent()
+        sut.awaitContactPreparation()
+
+        assertFalse(cacheData.value.cleanupPending)
+        verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY)
+        sut.closeAndClear()
+    }
+
     @Test
     fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test {
         settingsData.value = SettingsData(
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
index 30f48762a..eda68d8ea 100644
--- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
 import org.junit.Test
 import org.mockito.Mockito.clearInvocations
 import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
 import org.mockito.kotlin.doSuspendableAnswer
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.inOrder
@@ -40,6 +41,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
     private val context: Context = mock()
     private val pubkyRepo: PubkyRepo = mock()
     private val privatePaykitRepo: PrivatePaykitRepo = mock()
+    private val contacts = MutableStateFlow<List<PubkyProfile>>(emptyList())
 
     @Test
     fun `disconnect finishes private cleanup after the screen is closed`() = test {
@@ -318,6 +320,28 @@ class EditProfileViewModelTest : BaseUnitTest() {
         }
     }
 
+    @Test
+    fun `stopped deletion reconciles withdrawn private endpoints`() = test {
+        whenever(pubkyRepo.deleteProfile()).thenReturn(Result.failure(TestAppError("contact retained")))
+        contacts.value = listOf(createProfile())
+        whenever { privatePaykitRepo.retryPendingEndpointRemoval(any()) }.thenReturn(Result.success(Unit))
+        whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) }
+            .thenReturn(Result.success(Unit))
+        val sut = createSut()
+        advanceUntilIdle()
+
+        sut.deleteProfile()
+        advanceUntilIdle()
+
+        assertTrue(sut.uiState.value.showDeleteFailureDialog)
+        inOrder(privatePaykitRepo).apply {
+            verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
+            verify(privatePaykitRepo).endProfileDeletion()
+            verify(privatePaykitRepo).retryPendingEndpointRemoval(listOf(TEST_PUBLIC_KEY))
+            verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull())
+        }
+    }
+
     @Test
     fun `deleteProfile should show retry dialog when delete still fails`() = test {
         whenever(pubkyRepo.deleteProfile()).thenReturn(
@@ -426,6 +450,10 @@ class EditProfileViewModelTest : BaseUnitTest() {
         whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
             .thenReturn(Result.success(Unit))
         whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit))
+        whenever(pubkyRepo.contacts).thenReturn(contacts)
+        whenever { privatePaykitRepo.retryPendingEndpointRemoval(any()) }.thenReturn(Result.success(Unit))
+        whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) }
+            .thenReturn(Result.success(Unit))
 
         return EditProfileViewModel(
             context = context,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The retained cleanup flag and the missing immediate follow-up are real at this head. I am addressing this with the original-owner/restart repair tracked in #1687 rather than automatically republishing after an uncertain destructive operation. That candidate persists a removal obligation before cleanup, shows pending recovery, and offers read-only Review followed by a new confirmation or an explicitly local Disconnect. It deliberately keeps authenticated publication paused until the obligation is resolved. Its UI integration and controlled retry tests are under final validation; it is not published yet. I will link the follow-up and its evidence here. Leaving this thread open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The integrated recovery follow-up is now published in #1693 (stacked on this PR). It persists pending removal before destructive work, keeps authenticated publication paused after an uncertain outcome, and exposes read-only Review with a fresh original-owner confirmation or explicit local-only Disconnect. It deliberately does not republish automatically after a potentially completed deletion. Final 4,725 unit/component tests, full compile/format/lint and three installed Compose recovery cases passed; two consecutive independent final reviews are clean and both commits are signed/verified. Complete authenticated fault journeys and native contact CAS remain documented gaps in #1687. Leaving this thread open for review of that recovery behavior rather than claiming the parent alone restores publication safely.

pubkyRepo.deleteProfile().getOrThrow()
}
if (result.isSuccess) {
privatePaykitRepo.closeAndClear()
privatePaykitRepo.closeAndClear().onFailure {
Logger.error("Failed to clear local state after profile deletion", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
return
}
ToastEventBus.send(
type = Toast.ToastType.SUCCESS,
title = context.getString(_uiState.value.removal.successToast),
Expand Down
84 changes: 84 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,90 @@ class PubkyRepoTest : BaseUnitTest() {
verify(pubkyService, never()).removeContact(any())
}

@Test
fun `partial contact cleanup preserves retained contacts and stops profile deletion`() = test {
authenticateForTesting()
val records = listOf(
createContactRecord(VALID_CONTACT_KEY_A, label = "Removed"),
createContactRecord(VALID_CONTACT_KEY_B, label = "Retained"),
)
val overrides = records.associate {
it.publicKey to PubkyProfile.placeholder(it.publicKey).toProfileData()
}
var stored = PubkyStoreData(contactProfileOverrides = overrides)
whenever(pubkyStore.data).thenReturn(flowOf(stored))
whenever(pubkyStore.update(any())).thenAnswer {
stored = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(stored)
Unit
}
whenever(pubkyService.contactRecords()).thenReturn(records)
whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(listOf(records.first()))
sut.loadContacts()

val result = sut.deleteProfile()

assertTrue(result.isFailure)
assertEquals(listOf(VALID_CONTACT_KEY_B), sut.contacts.value.map { it.publicKey })
assertEquals(setOf(VALID_CONTACT_KEY_B), stored.contactProfileOverrides.keys)
verify(pubkyService, never()).deletePaykitProfile()
verify(pubkyService, never()).signOut()
}

@Test
fun `unavailable contact read does not authorize profile deletion`() = test {
authenticateForTesting()
val error = TestAppError("Fetch failed: 404 missing session")
whenever(pubkyService.contactRecords()).thenAnswer { throw error }
clearInvocations(pubkyStore)

val result = sut.deleteProfile()

assertSame(error, result.exceptionOrNull())
verify(pubkyStore, never()).update(any())
verify(pubkyService, never()).removeContacts(any())
verify(pubkyService, never()).deletePaykitProfile()
verify(pubkyService, never()).signOut()
}

@Test
fun `uncertain contact cleanup preserves local evidence and stops profile deletion`() = test {
authenticateForTesting()
val record = createContactRecord(VALID_CONTACT_KEY_A, label = "Retained")
val records = listOf(record)
val error = TestAppError("Contact removal acknowledgement lost")
whenever(pubkyService.contactRecords()).thenReturn(records)
whenever(pubkyService.removeContacts(listOf(record.publicKey))).thenAnswer { throw error }
sut.loadContacts()
clearInvocations(pubkyStore)

val result = sut.deleteProfile()

assertSame(error, result.exceptionOrNull())
assertEquals(listOf(VALID_CONTACT_KEY_A), sut.contacts.value.map { it.publicKey })
verify(pubkyStore, never()).update(any())
verify(pubkyService, never()).deletePaykitProfile()
verify(pubkyService, never()).signOut()
}

@Test
fun `profile deletion errors mentioning missing data do not authorize sign out`() = test {
authenticateForTesting()
settingsFlow.value = SettingsData(
sharesPublicPaykitEndpoints = true,
sharesPrivatePaykitEndpoints = true,
)
val error = TestAppError("Delete failed: HTTP 404 Not Found at session endpoint")
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw error }

val result = sut.deleteProfile()

assertSame(error, result.exceptionOrNull())
assertTrue(sut.isAuthenticated.value)
assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints)
assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints)
verify(pubkyService, never()).signOut()
}

@Test
fun `deleteProfile should fail when signOut fails`() = test {
authenticateForTesting()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,49 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertEquals("https://updated.example.com", sut.uiState.value.links.first().url)
}

@Test
fun `failed private cleanup stops deletion and preserves pending state`() = test {
val sut = createSut()
advanceUntilIdle()
whenever(privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)))
.thenReturn(Result.failure(TestAppError("Withdrawal pending")))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))

sut.effects.test {
sut.deleteProfile()
advanceUntilIdle()
expectNoEvents()
}

assertTrue(sut.uiState.value.showDeleteFailureDialog)
assertFalse(sut.uiState.value.isDeleting)
verify(pubkyRepo, never()).deleteProfile()
verify(privatePaykitRepo, never()).closeAndClear()
verify(privatePaykitRepo).endProfileDeletion()
}

@Test
fun `failed local finalization does not report deletion success`() = test {
val sut = createSut()
advanceUntilIdle()
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
whenever(
privatePaykitRepo.closeAndClear()
).thenReturn(Result.failure(TestAppError("Local storage unavailable")))

sut.effects.test {
sut.deleteProfile()
advanceUntilIdle()
expectNoEvents()
}

assertFalse(sut.uiState.value.showDeleteFailureDialog)
assertFalse(sut.uiState.value.isDeleting)
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
verify(privatePaykitRepo).endProfileDeletion()
}

@Test
fun `deleteProfile should emit success when repository delete succeeds`() = test {
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
Expand Down Expand Up @@ -349,29 +392,6 @@ class EditProfileViewModelTest : BaseUnitTest() {
verify(privatePaykitRepo, never()).closeAndClear()
}

@Test
fun `deleteProfile should continue when private cleanup fails`() = test {
val sut = createSut()
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
.thenReturn(Result.failure(TestAppError("cleanup failed")))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
advanceUntilIdle()

sut.effects.test {
sut.deleteProfile()
advanceUntilIdle()

assertEquals(EditProfileEffect.DeleteSuccess, awaitItem())
}
assertFalse(sut.uiState.value.showDeleteFailureDialog)
assertFalse(sut.uiState.value.isDeleting)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `dismissDeleteFailureDialog should hide retry dialog`() = test {
whenever(pubkyRepo.deleteProfile()).thenReturn(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1690.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserve session and contact data after failed profile deletion or incomplete payment-link cleanup.
20 changes: 20 additions & 0 deletions journeys/profile/profile-cleanup-failure.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<journey name="profile cleanup failure">
<description>
Requires a disposable test identity with saved contacts and a controlled homeserver proxy.
Never use a personal profile. Inject a private-endpoint withdrawal failure, then in a fresh
fixture inject an unknown contact-removal response or a partial removal retaining an active peer.
Also inject a profile-deletion error whose message contains 404 Not Found, without confirming deletion.
These controls are not available in standard staging. This journey covers stopping on incomplete
cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation.
</description>
<actions>
<action>Open Edit Profile and confirm deletion with the cleanup fault enabled</action>
<action>Verify failure options appear without a success notification or onboarding navigation</action>
<action>Verify a failed private withdrawal is followed by no contact or profile deletion</action>
<action>Verify unknown contact removal keeps local contact data and prevents profile deletion</action>
<action>Verify partial contact removal preserves contacts and overrides not acknowledged as removed</action>
<action>Verify an uncertain profile-deletion error preserves the session and sharing settings without success feedback</action>
<action>Cancel the failure dialog and verify the app starts no second deletion</action>
<action>Restart the app and verify no profile deletion is automatically replayed</action>
</actions>
</journey>
Loading