Repository navigation
fix: stop deletion after incomplete cleanup #1690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fix/profile-delete-session-recovery
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -243,7 +243,7 @@ class EditProfileViewModel @Inject constructor( | |
| privatePaykitRepo.removePublishedEndpointsForCleanup(TAG).getOrThrow() | ||
| pubkyRepo.signOut().getOrThrow() | ||
| } | ||
| privatePaykitRepo.closeAndClear() | ||
| privatePaykitRepo.closeAndClear().getOrThrow() | ||
| } | ||
| } | ||
| if (result.isSuccess) { | ||
|
|
@@ -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() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 ( Fix: keep the flag (the crash-recovery test withContext(NonCancellable) {
privatePaykitRepo.endProfileDeletion()
if (_uiState.value.showDeleteFailureDialog) {
privatePaykitRepo.retryPendingEndpointRemoval(pubkyRepo.contacts.value.map { it.publicKey })
privatePaykitRepo.refreshKnownSavedContactEndpoints(TAG)
}
}In production, route this through LOW. Reproduction
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,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
|
|
||
| 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. |
| 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> |
There was a problem hiding this comment.
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_RECURRINGpayment 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 callingremoveContactsAndBlockPeers. This is a deliberate retention policy:PaykitSdkServiceTestactive subscription prevents deletion until it endsasserts it returns an empty list. In this PR,deleteAllContactscompares the returned keys to every record and throwsPrivatePaykitError.PrivateUnavailablewhen they differ. By the time it throws,attemptDeleteProfilehas 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
2daec90and paykit-rsf384694(remove_contacts_and_block_peersdoc: 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.