From 618087aa45e70e8a7b7d7298419b3e74bdc325f8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 05:36:27 +0100 Subject: [PATCH 1/4] fix: stop profile deletion after incomplete cleanup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 24 +++---- .../screens/profile/EditProfileViewModel.kt | 18 +++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 65 ++++++++++++++++++ .../profile/EditProfileViewModelTest.kt | 66 ++++++++++++------- changelog.d/next/profile-cleanup.fixed.md | 1 + journeys/profile/profile-cleanup-failure.xml | 18 +++++ 6 files changed, 149 insertions(+), 43 deletions(-) create mode 100644 changelog.d/next/profile-cleanup.fixed.md create mode 100644 journeys/profile/profile-cleanup-failure.xml diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e92362b4a5..ae7765e8b1 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1108,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) } 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 28ad1a79a0..412fcb5dac 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1356,6 +1356,71 @@ 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 `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/profile-cleanup.fixed.md b/changelog.d/next/profile-cleanup.fixed.md new file mode 100644 index 0000000000..e4eaf87e34 --- /dev/null +++ b/changelog.d/next/profile-cleanup.fixed.md @@ -0,0 +1 @@ +Preserve contact data and stop profile deletion when payment-link or contact cleanup is incomplete. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml new file mode 100644 index 0000000000..ab494d5d10 --- /dev/null +++ b/journeys/profile/profile-cleanup-failure.xml @@ -0,0 +1,18 @@ + + + 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. + 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 + Cancel the failure dialog and verify the app starts no second deletion + Restart the app and verify no profile deletion is automatically replayed + + From 479c53065167ed85b99393f79d1b0b83827ca134 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 05:37:07 +0100 Subject: [PATCH 2/4] chore: rename changelog fragment --- changelog.d/next/{profile-cleanup.fixed.md => 1690.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{profile-cleanup.fixed.md => 1690.fixed.md} (100%) diff --git a/changelog.d/next/profile-cleanup.fixed.md b/changelog.d/next/1690.fixed.md similarity index 100% rename from changelog.d/next/profile-cleanup.fixed.md rename to changelog.d/next/1690.fixed.md From 2daec90e2651f6ed94a72fe5b5ccd5847cb5aa4b Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 06:33:07 +0100 Subject: [PATCH 3/4] fix: preserve sessions after uncertain profile deletion --- .../java/to/bitkit/repositories/PubkyRepo.kt | 25 +------------------ .../to/bitkit/repositories/PubkyRepoTest.kt | 19 ++++++++++++++ changelog.d/next/1690.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 2 ++ 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ae7765e8b1..0ddfd7e599 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1093,14 +1093,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()) } @@ -2254,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 } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 412fcb5dac..a4d18e2836 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1421,6 +1421,25 @@ class PubkyRepoTest : BaseUnitTest() { 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/changelog.d/next/1690.fixed.md b/changelog.d/next/1690.fixed.md index e4eaf87e34..f05f0a6f84 100644 --- a/changelog.d/next/1690.fixed.md +++ b/changelog.d/next/1690.fixed.md @@ -1 +1 @@ -Preserve contact data and stop profile deletion when payment-link or contact cleanup is incomplete. +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 index ab494d5d10..f9fd8c2e29 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -3,6 +3,7 @@ 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. @@ -12,6 +13,7 @@ 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 From 7afdd3e3ba88ab5dded792073a44b4a975728ebb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 02:41:50 +0200 Subject: [PATCH 4/4] fix: block profile removal with active subscriptions --- .../java/to/bitkit/repositories/PubkyRepo.kt | 13 +++- .../to/bitkit/services/PaykitSdkService.kt | 51 ++++++++++++---- .../java/to/bitkit/services/PubkyService.kt | 8 +++ .../screens/profile/EditProfileViewModel.kt | 21 +++++-- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/repositories/PubkyRepoTest.kt | 49 +++++++++++++-- .../bitkit/services/PaykitSdkServiceTest.kt | 45 ++++++++++++++ .../profile/EditProfileViewModelTest.kt | 60 +++++++++++++++++++ changelog.d/next/1690.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 9 ++- 25 files changed, 248 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ce29f89952..46949cf7cd 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1093,11 +1093,19 @@ class PubkyRepo @Inject constructor( } } + suspend fun checkProfileDeletionAllowed(): Result = runSuspendCatching { + withContext(ioDispatcher) { pubkyService.checkProfileDeletionAllowed() } + }.recoverCatching { + if (it.hasCause(PubkyContactError.ActiveSubscription)) throw PubkyContactError.ActiveSubscription + throw it + } + suspend fun deleteProfile(): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } + checkProfileDeletionAllowed().getOrThrow() clearSessionContactProfiles() deleteAllContacts() pubkyService.deletePaykitProfile() @@ -1105,13 +1113,16 @@ class PubkyRepo @Inject constructor( } settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) } signOut().getOrThrow() + }.recoverCatching { + if (it.hasCause(PubkyContactError.ActiveSubscription)) throw PubkyContactError.ActiveSubscription + throw it } private suspend fun deleteAllContacts() { val records = pubkyService.contactRecords() if (records.isEmpty()) return val keys = records.map { it.publicKey.ensurePubkyPrefix() }.toSet() - val removedKeys = pubkyService.removeContacts(records.map { it.publicKey }) + val removedKeys = pubkyService.removeContactsForProfile(records.map { it.publicKey }) .map { it.publicKey.ensurePubkyPrefix() }.toSet().intersect(keys) pubkyStore.update { it.copy(contactProfileOverrides = it.contactProfileOverrides - removedKeys) } notifyBackupStateChanged() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 87c452f640..076e3fa652 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -764,6 +764,25 @@ class PaykitSdkService @Inject constructor( return imported } + suspend fun checkProfileDeletionAllowed() { + isSetup.await() + operationLock.withLock { + withStateRevisionTracking { handle -> + val now = nowMillis() + if (completeSdkCall { handle.paymentRequests() }.any { it.hasActiveSubscription(now) }) { + throw PubkyContactError.ActiveSubscription + } + } + } + } + + private fun PaymentRequestRecord.hasActiveSubscription(now: Long): Boolean { + val endsAt = terms?.recurrence?.endsAt?.let { timestamp -> + runCatching { Instant.parse(timestamp).toEpochMilliseconds() }.getOrNull() + } + return state == PaymentRequestLifecycleState.ACTIVE_RECURRING && (endsAt == null || endsAt > now) + } + suspend fun removeContact(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { @@ -773,12 +792,7 @@ class PaykitSdkService @Inject constructor( } val now = nowMillis() val hasActiveSubscription = completeSdkCall { handle.paymentRequests() }.any { - val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> - runSuspendCatching { Instant.parse(timestamp).toEpochMilliseconds() }.getOrNull() - } - PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && - it.state == PaymentRequestLifecycleState.ACTIVE_RECURRING && - (endsAt == null || endsAt > now) + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.hasActiveSubscription(now) } if (hasActiveSubscription) throw PubkyContactError.ActiveSubscription peers.filter { it.state == LinkedPeerState.LINKED }.forEach { @@ -798,18 +812,29 @@ class PaykitSdkService @Inject constructor( } } - suspend fun removeContacts(publicKeys: List): List { + suspend fun removeContacts(publicKeys: List): List = + removeContacts(publicKeys, requireAll = false) + + suspend fun removeContactsForProfile(publicKeys: List): List = + removeContacts(publicKeys, requireAll = true) + + private suspend fun removeContacts(publicKeys: List, requireAll: Boolean): List { if (publicKeys.isEmpty()) return emptyList() isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> val now = nowMillis() - val subscribedKeys = completeSdkCall { handle.paymentRequests() }.filter { - val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> - runSuspendCatching { Instant.parse(timestamp).toEpochMilliseconds() }.getOrNull() - } - it.state == PaymentRequestLifecycleState.ACTIVE_RECURRING && (endsAt == null || endsAt > now) - }.mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) }.toSet() + val subscriptions = completeSdkCall { handle.paymentRequests() }.filter { + it.hasActiveSubscription( + now + ) + } + if (requireAll && subscriptions.isNotEmpty()) throw PubkyContactError.ActiveSubscription + val subscribedKeys = subscriptions.mapNotNull { + PubkyPublicKeyFormat.normalized( + it.counterparty + ) + }.toSet() val removableKeys = publicKeys.filter { PubkyPublicKeyFormat.normalized(it) !in subscribedKeys } if (removableKeys.isEmpty()) return@withStateRevisionTracking emptyList() completeSdkCall { handle.removeContactsAndBlockPeers(removableKeys) } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index e9105a40c9..18cf817116 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -207,6 +207,14 @@ class PubkyService @Inject constructor( paykitSdkService.uploadProfileAvatar(bytes, contentType) } + suspend fun checkProfileDeletionAllowed() = ServiceQueue.CORE.background { + paykitSdkService.checkProfileDeletionAllowed() + } + + suspend fun removeContactsForProfile(publicKeys: List): List = ServiceQueue.CORE.background { + paykitSdkService.removeContactsForProfile(publicKeys) + } + suspend fun deletePaykitProfile() = ServiceQueue.CORE.background { paykitSdkService.deletePaykitProfile() } 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 f72026cafe..793726d570 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 @@ -25,6 +25,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -270,9 +271,12 @@ class EditProfileViewModel @Inject constructor( isDeleting = true, ) } + var privatePauseStarted = false try { - privatePaykitRepo.beginProfileDeletion() val result = runSuspendCatching { + pubkyRepo.checkProfileDeletionAllowed().getOrThrow() + privatePauseStarted = true + privatePaykitRepo.beginProfileDeletion() privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow() pubkyRepo.deleteProfile().getOrThrow() } @@ -293,11 +297,20 @@ class EditProfileViewModel @Inject constructor( _effects.emit(EditProfileEffect.DeleteSuccess) } else { val error = requireNotNull(result.exceptionOrNull()) { "Profile delete failed without an error" } - Logger.error("Failed to delete profile", error, context = TAG) - _uiState.update { it.copy(showDeleteFailureDialog = true) } + if (error == PubkyContactError.ActiveSubscription) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__delete_active_subscription), + ) + } else { + Logger.error("Failed to delete profile", error, context = TAG) + _uiState.update { it.copy(showDeleteFailureDialog = true) } + } } } finally { - withContext(NonCancellable) { privatePaykitRepo.endProfileDeletion() } + if (privatePauseStarted) { + withContext(NonCancellable) { privatePaykitRepo.endProfileDeletion() } + } _uiState.update { it.copy(isDeleting = false) } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 045e83ac9b..a3dcb89358 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1297,6 +1297,7 @@ جارٍ تحميل ملفك الشخصي… ادخل إلى\n<accent>ويب الحرية</accent> فشل إنشاء الملف الشخصي + أنه الاشتراكات النشطة قبل إزالة هذا الملف الشخصي. يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا. حذف الملف الشخصي؟ تعذر علينا حذف بيانات ملفك الشخصي. أعد المحاولة، أو افصل هذا الـ pubky عن Bitkit. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 2e3d9b1f9f..49633ef5b0 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1323,6 +1323,7 @@ Cargando su perfil… Entre a la\n<accent>web de la libertad</accent> No se pudo crear el perfil + Finaliza las suscripciones activas antes de eliminar este perfil. Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente. ¿Eliminar perfil? No pudimos eliminar los datos de su perfil. Vuelva a intentarlo o desconecte este pubky de Bitkit. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 24dcb34d0a..f4f9a51d03 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1319,6 +1319,7 @@ Carregant el teu perfil… Entra a la\n<accent>web de la llibertat</accent> No s\'ha pogut crear el perfil + Finalitza les subscripcions actives abans d’eliminar aquest perfil. Eliminar el teu perfil l\'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s\'hi hagi compartit abans. Eliminar el perfil? No hem pogut eliminar les dades del teu perfil. Torna-ho a provar o desconnecta aquest pubky de Bitkit. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0336264d1..998983747d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1321,6 +1321,7 @@ Načítání vašeho profilu… Vstupte do\n<accent>svobodného webu</accent> Profil se nepodařilo vytvořit + Před odstraněním tohoto profilu ukončete aktivní předplatná. Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli. Smazat profil? Data vašeho profilu se nepodařilo smazat. Zkuste to znovu, nebo tento pubky od Bitkitu odpojte. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a45ff0fe4..eae0affb2c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1319,6 +1319,7 @@ Dein Profil wird geladen… Betritt das\n<accent>freie Web</accent> Profil konnte nicht erstellt werden + Beende aktive Abonnements, bevor du dieses Profil entfernst. Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten. Profil löschen? Wir konnten deine Profildaten nicht löschen. Versuche es erneut oder trenne diesen pubky von Bitkit. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 8587ae5710..29d0a85d45 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1303,6 +1303,7 @@ Φόρτωση του προφίλ σου… Μπες στον\n<accent>ελεύθερο ιστό</accent> Αποτυχία δημιουργίας προφίλ + Τερμάτισε τις ενεργές συνδρομές πριν αφαιρέσεις αυτό το προφίλ. Η διαγραφή του προφίλ σου το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα. Διαγραφή προφίλ; Δεν μπορέσαμε να διαγράψουμε τα δεδομένα του προφίλ σου. Δοκίμασε ξανά ή αποσύνδεσε αυτό το pubky από το Bitkit. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9d926c39bd..d989aa673f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1317,6 +1317,7 @@ Cargando tu perfil… Entra en la\n<accent>web libre</accent> No se pudo crear el perfil + Finaliza las suscripciones activas antes de eliminar este perfil. Al eliminar tu perfil, se elimina de Pubky. Es posible que tus contactos conserven la información de perfil y de pago que compartiste con ellos anteriormente. ¿Eliminar el perfil? No pudimos eliminar los datos de tu perfil. Vuelve a intentarlo o desconecta este pubky de Bitkit. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 940f3de425..bc05be7c2b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1316,6 +1316,7 @@ Cargando su perfil… Entre en la\n<accent>web libre</accent> No se pudo crear el perfil + Finaliza las suscripciones activas antes de eliminar este perfil. Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente. ¿Eliminar el perfil? No pudimos eliminar los datos de su perfil. Vuelva a intentarlo o desconecte este pubky de Bitkit. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61cd95d307..b4032906f9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1317,6 +1317,7 @@ Chargement de votre profil… Entrez dans le\n<accent>web de la liberté</accent> Échec de la création du profil + Mettez fin aux abonnements actifs avant de supprimer ce profil. La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées. Supprimer le profil ? Nous n\'avons pas pu supprimer les données de votre profil. Réessayez ou déconnectez ce pubky de Bitkit. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2141ffc645..cc3402e604 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1322,6 +1322,7 @@ Caricamento del profilo… Entra nel\n<accent>web della libertà</accent> Impossibile creare il profilo + Termina gli abbonamenti attivi prima di rimuovere questo profilo. Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro. Eliminare il Profilo? Non è stato possibile eliminare i dati del tuo profilo. Riprova, oppure disconnetti questo pubky da Bitkit. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0f16a7c531..c8bc711174 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1319,6 +1319,7 @@ Uw profiel laden… Betreed het\n<accent>vrije web</accent> Profiel aanmaken mislukt + Beëindig actieve abonnementen voordat je dit profiel verwijdert. Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren. Profiel verwijderen? We konden uw profielgegevens niet verwijderen. Probeer het opnieuw, of ontkoppel deze pubky van Bitkit. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d1a5a29132..8e7d1cdf09 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1320,6 +1320,7 @@ Wczytywanie Twojego profilu… Wejdź do\n<accent>wolnej sieci</accent> Nie udało się utworzyć profilu + Zakończ aktywne subskrypcje przed usunięciem tego profilu. Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności. Usunąć profil? Nie udało się usunąć danych Twojego profilu. Spróbuj ponownie lub odłącz ten pubky od Bitkit. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0266a4b1e4..d893d9b6bf 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1322,6 +1322,7 @@ Carregando seu perfil… Entre na\n<accent>web da liberdade</accent> Falha ao criar perfil + Encerre as assinaturas ativas antes de remover este perfil. Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles. Excluir Perfil? Não conseguimos excluir os dados do seu perfil. Tente novamente ou desconecte esta pubky da Bitkit. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c9f113920f..fddd7e88ea 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1296,6 +1296,7 @@ A carregar o seu perfil… Entre na\n<accent>web da liberdade</accent> Falha ao criar perfil + Termina as subscrições ativas antes de remover este perfil. Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles. Eliminar perfil? Não foi possível eliminar os dados do seu perfil. Tente novamente ou desligue este pubky da Bitkit. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cd61bf299f..18e67a6899 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1337,6 +1337,7 @@ Загрузка вашего профиля… Войдите в\n<accent>свободный веб</accent> Не удалось создать профиль + Завершите активные подписки перед удалением этого профиля. Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее. Удалить Профиль? Не удалось удалить данные вашего профиля. Повторите попытку или отключите этот pubky от Bitkit. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0f1418c67..eb64b1d4a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -630,6 +630,7 @@ Enter the\n<accent>freedom web</accent> Failed to create profile Create Profile + End active subscriptions before removing this profile. Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them. Delete Profile? We couldn\'t delete your profile data. Retry, or disconnect this pubky from Bitkit. diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 241ea156b3..a86492a240 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1313,7 +1313,7 @@ class PubkyRepoTest : BaseUnitTest() { createContactRecord(VALID_CONTACT_KEY_B, label = "Bob"), ) whenever(pubkyService.contactRecords()).thenReturn(records) - whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(records) + whenever(pubkyService.removeContactsForProfile(records.map { it.publicKey })).thenReturn(records) val bulkCancelled = CompletableDeferred() val screenCancelled = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) @@ -1352,10 +1352,45 @@ class PubkyRepoTest : BaseUnitTest() { deletion.await().getOrThrow() screenLookup.await() assertTrue(sut.contacts.value.isEmpty()) - verify(pubkyService).removeContacts(records.map { it.publicKey }) + verify(pubkyService).removeContactsForProfile(records.map { it.publicKey }) verify(pubkyService, never()).removeContact(any()) } + @Test + fun `active subscription is preserved through service wrappers before profile cleanup`() = test { + authenticateForTesting() + val wrapped = IllegalStateException("Service operation failed", PubkyContactError.ActiveSubscription) + whenever(pubkyService.checkProfileDeletionAllowed()).thenAnswer { throw wrapped } + clearInvocations(pubkyStore, pubkyService) + + assertSame(PubkyContactError.ActiveSubscription, sut.checkProfileDeletionAllowed().exceptionOrNull()) + assertSame(PubkyContactError.ActiveSubscription, sut.deleteProfile().exceptionOrNull()) + + verify(pubkyService, never()).contactRecords() + verify(pubkyService, never()).removeContactsForProfile(any()) + verify(pubkyService, never()).deletePaykitProfile() + verify(pubkyService, never()).signOut() + verify(pubkyStore, never()).update(any()) + } + + @Test + fun `late subscription rejection retains contacts and credentials`() = test { + authenticateForTesting() + val records = listOf(createContactRecord(VALID_CONTACT_KEY_A, label = "Retained")) + whenever(pubkyService.contactRecords()).thenReturn(records) + sut.loadContacts() + val wrapped = IllegalStateException("Service operation failed", PubkyContactError.ActiveSubscription) + whenever(pubkyService.removeContactsForProfile(any())).thenAnswer { throw wrapped } + clearInvocations(pubkyStore) + + assertSame(PubkyContactError.ActiveSubscription, sut.deleteProfile().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 `partial contact cleanup preserves retained contacts and stops profile deletion`() = test { authenticateForTesting() @@ -1373,7 +1408,9 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(records) - whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(listOf(records.first())) + whenever( + pubkyService.removeContactsForProfile(records.map { it.publicKey }) + ).thenReturn(listOf(records.first())) sut.loadContacts() val result = sut.deleteProfile() @@ -1396,7 +1433,7 @@ class PubkyRepoTest : BaseUnitTest() { assertSame(error, result.exceptionOrNull()) verify(pubkyStore, never()).update(any()) - verify(pubkyService, never()).removeContacts(any()) + verify(pubkyService, never()).removeContactsForProfile(any()) verify(pubkyService, never()).deletePaykitProfile() verify(pubkyService, never()).signOut() } @@ -1408,7 +1445,7 @@ class PubkyRepoTest : BaseUnitTest() { 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 } + whenever(pubkyService.removeContactsForProfile(listOf(record.publicKey))).thenAnswer { throw error } sut.loadContacts() clearInvocations(pubkyStore) @@ -4776,7 +4813,7 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") val records = listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved")) - whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(records) + whenever(pubkyService.removeContactsForProfile(records.map { it.publicKey })).thenReturn(records) whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Offline") } val newRead = createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice", "New")) val oldRead = holdScreenLookup(VALID_CONTACT_KEY_A, laterRead = newRead) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index f4056f8672..b9d07e0483 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2090,6 +2090,10 @@ class PaykitSdkServiceTest { val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } assertTrue(service.removeContacts(listOf(RING_PUBKY)).isEmpty()) + assertFailsWith { service.checkProfileDeletionAllowed() } + assertFailsWith { + service.removeContactsForProfile(listOf(RING_PUBKY, "pubkyunrelated")) + } verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) verify(sdk, never()).removeContactsAndBlockPeers(any()) @@ -2103,9 +2107,50 @@ class PaykitSdkServiceTest { val removed = listOf(mock()) whenever(sdk.removeContactsAndBlockPeers(listOf(RING_PUBKY))).thenReturn(removed) assertEquals(removed, service.removeContacts(listOf(RING_PUBKY))) + service.checkProfileDeletionAllowed() + assertEquals(removed, service.removeContactsForProfile(listOf(RING_PUBKY))) } } + @Test + fun `profile removal conservatively blocks unknown or future subscription expiry`() = runTest { + for (endsAt in listOf(null, "invalid", "2999-01-01T00:00:00Z")) { + val sdk = mock() + val recurrence = mock { on { this.endsAt } doReturn endsAt } + val requestTerms = mock { on { this.recurrence } doReturn recurrence } + val request = mock { + on { counterparty } doReturn RING_PUBKY + on { state } doReturn PaymentRequestLifecycleState.ACTIVE_RECURRING + on { terms } doReturn requestTerms + } + whenever(sdk.paymentRequests()).thenReturn(listOf(request)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertFailsWith { service.checkProfileDeletionAllowed() } + assertFailsWith { + service.removeContactsForProfile(listOf("pubkyunrelated")) + } + verify(sdk, never()).removeContactsAndBlockPeers(any()) + } + } + + @Test + fun `profile removal fails closed when subscriptions cannot be read`() = runTest { + val sdk = mock() + val error = IllegalStateException("Subscription storage unavailable") + whenever(sdk.paymentRequests()).thenAnswer { throw error } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertSame(error, assertFailsWith { service.checkProfileDeletionAllowed() }) + assertSame( + error, + assertFailsWith { + service.removeContactsForProfile(listOf(RING_PUBKY)) + } + ) + verify(sdk, never()).removeContactsAndBlockPeers(any()) + } + @Test fun `unreadable profile cache stops activation`() = runTest { for (error in listOf( 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 30f48762ae..7045867cb3 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 @@ -24,6 +24,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -41,6 +42,64 @@ class EditProfileViewModelTest : BaseUnitTest() { private val pubkyRepo: PubkyRepo = mock() private val privatePaykitRepo: PrivatePaykitRepo = mock() + @Test + fun `active subscription stops profile deletion before private or contact cleanup`() = test { + val sut = createSut() + whenever( + pubkyRepo.checkProfileDeletionAllowed() + ).thenReturn(Result.failure(PubkyContactError.ActiveSubscription)) + whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit)) + advanceUntilIdle() + + sut.deleteProfile() + advanceUntilIdle() + + verify(pubkyRepo).checkProfileDeletionAllowed() + verify(context).getString(R.string.profile__delete_active_subscription) + verify(privatePaykitRepo, never()).beginProfileDeletion() + verify(privatePaykitRepo, never()).endProfileDeletion() + verify(privatePaykitRepo, never()).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) + verify(privatePaykitRepo, never()).closeAndClear() + verify(pubkyRepo, never()).deleteProfile() + assertFalse(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.isDeleting) + } + + @Test + fun `unavailable subscription read retains cleanup and shows failure`() = test { + val sut = createSut() + whenever(pubkyRepo.checkProfileDeletionAllowed()).thenReturn(Result.failure(TestAppError("Unavailable"))) + advanceUntilIdle() + + sut.deleteProfile() + advanceUntilIdle() + + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.isDeleting) + verify(privatePaykitRepo, never()).beginProfileDeletion() + verify(privatePaykitRepo, never()).endProfileDeletion() + verify(pubkyRepo, never()).deleteProfile() + } + + @Test + fun `cancellation during subscription read does not start or end private cleanup`() = test { + val sut = createSut() + val read = CompletableDeferred>() + whenever(pubkyRepo.checkProfileDeletionAllowed()).doSuspendableAnswer { read.await() } + advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() + + sut.viewModelScope.cancel() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isDeleting) + assertFalse(sut.uiState.value.showDeleteFailureDialog) + verify(privatePaykitRepo, never()).beginProfileDeletion() + verify(privatePaykitRepo, never()).endProfileDeletion() + verify(pubkyRepo, never()).deleteProfile() + } + @Test fun `disconnect finishes private cleanup after the screen is closed`() = test { val sut = createSut() @@ -418,6 +477,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } else { whenever { pubkyRepo.isRingAdoptedIdentity() }.thenReturn(isRingIdentity) } + whenever { pubkyRepo.checkProfileDeletionAllowed() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.forgetUnrestoredIdentity() }.thenReturn(Result.success(false)) whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(createProfile())) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) diff --git a/changelog.d/next/1690.fixed.md b/changelog.d/next/1690.fixed.md index f05f0a6f84..ee2cf196bc 100644 --- a/changelog.d/next/1690.fixed.md +++ b/changelog.d/next/1690.fixed.md @@ -1 +1 @@ -Preserve session and contact data after failed profile deletion or incomplete payment-link cleanup. +Preserve profile data when cleanup is incomplete and explain when active subscriptions prevent removal. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index f9fd8c2e29..5b1f66be99 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -2,12 +2,19 @@ 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. + fixture inject an unknown contact-removal response or a partial removal acknowledging only some contacts. + Separately prepare an active recurring subscription in each role (payer and payee), with an + unrelated saved contact, and record endpoint/contact state before confirming removal. Verify + the subscription warning appears before cleanup; cancel or expire the subscription in the + disposable fixture and confirm removal again to verify it can proceed. 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. + With an active subscription, confirm profile deletion or unlink and verify the localized instruction to end active subscriptions + Verify there is no generic Retry dialog, endpoint withdrawal, contact removal or profile deletion, including for the unrelated contact + After ending the subscription, explicitly confirm removal again and verify acknowledged cleanup succeeds 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