diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index fda2144865..ce29f89952 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1100,14 +1100,7 @@ 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()) } @@ -1115,27 +1108,19 @@ class PubkyRepo @Inject constructor( } 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 Logger.info("Deleted all contacts", context = TAG) } @@ -2269,21 +2254,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 } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index 06bb4bd4c5..f72026cafe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -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() + 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), diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ea3c7e60c8..241ea156b3 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -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() 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 381c03e5e4..30f48762ae 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 @@ -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)) @@ -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( diff --git a/changelog.d/next/1690.fixed.md b/changelog.d/next/1690.fixed.md new file mode 100644 index 0000000000..f05f0a6f84 --- /dev/null +++ b/changelog.d/next/1690.fixed.md @@ -0,0 +1 @@ +Preserve session and contact data after failed profile deletion or incomplete payment-link cleanup. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml new file mode 100644 index 0000000000..f9fd8c2e29 --- /dev/null +++ b/journeys/profile/profile-cleanup-failure.xml @@ -0,0 +1,20 @@ + + + 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. + + + Open Edit Profile and confirm deletion with the cleanup fault enabled + Verify failure options appear without a success notification or onboarding navigation + Verify a failed private withdrawal is followed by no contact or profile deletion + Verify unknown contact removal keeps local contact data and prevents profile deletion + Verify partial contact removal preserves contacts and overrides not acknowledged as removed + Verify an uncertain profile-deletion error preserves the session and sharing settings without success feedback + Cancel the failure dialog and verify the app starts no second deletion + Restart the app and verify no profile deletion is automatically replayed + +