diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 7caf82b54..fde215be3 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -193,7 +193,7 @@ class ContactsManager: ObservableObject { _ = try await PubkyService.removeContact(publicKey: $0) }, removeContactRecords: @escaping @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] = { - try await PubkyService.removeContacts(publicKeys: $0) + try await PubkyService.removeContactsForProfile(publicKeys: $0) }, forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = { await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) @@ -972,22 +972,7 @@ class ContactsManager: ObservableObject { stopPendingProfileLookups() let contactRecords = contactRecords let removeContactRecords = removeContactRecords - let records: [ContactRecord] - do { - records = try await Task.detached { - try await contactRecords() - }.value - } catch { - guard Self.isMissingContactsDataError(error) else { - throw error - } - - Self.clearContactProfileOverrides() - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - contacts.removeAll() - Logger.info("Contacts storage missing, treating delete-all as empty", context: "ContactsManager") - return - } + let records = try await Task.detached { try await contactRecords() }.value let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } let removed = try await Task.detached { try await removeContactRecords(keys) }.value @@ -1006,17 +991,6 @@ class ContactsManager: ObservableObject { Logger.info("Deleted all contacts", context: "ContactsManager") } - func deleteAllContactsBestEffort() async { - do { - try await deleteAllContacts() - } catch { - Logger.warn("Continuing after contact cleanup failed: \(error)", context: "ContactsManager") - Self.clearContactProfileOverrides() - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - contacts.removeAll() - } - } - // MARK: - Remote Contact Discovery /// Looks every follow's profile up on the interactive read lane: the user waits on the choice screen until the import diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 04f7b583b..cd26cafea 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -122,6 +122,7 @@ class PubkyProfileManager: ObservableObject { private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher private let avatarUploader: AvatarUploader + private let profileDeletionValidator: ProfileDeleter private let profileDeleter: ProfileDeleter /// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started /// earlier is dropped. @@ -169,6 +170,9 @@ class PubkyProfileManager: ObservableObject { avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) }, + profileDeletionValidator: @escaping ProfileDeleter = { + try await PubkyService.requireNoActiveSubscriptions(expectedIdentity: $0) + }, profileDeleter: @escaping ProfileDeleter = { try await PubkyService.deletePaykitProfile(expectedIdentity: $0) } @@ -176,6 +180,7 @@ class PubkyProfileManager: ObservableObject { self.remoteProfileResolver = remoteProfileResolver self.profilePublisher = profilePublisher self.avatarUploader = avatarUploader + self.profileDeletionValidator = profileDeletionValidator self.profileDeleter = profileDeleter cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) @@ -1067,6 +1072,12 @@ class PubkyProfileManager: ObservableObject { await PrivatePaykitService.shared.endProfileDeletion() } + func validateProfileDeletion(for session: SignedInSession) async throws { + try requireCurrentSession(session) + try await profileDeletionValidator(session.publicKey) + try requireCurrentSession(session) + } + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { guard let session = expectedSession ?? currentSession else { throw PubkyServiceError.sessionNotActive } try requireCurrentSession(session) @@ -1082,26 +1093,18 @@ class PubkyProfileManager: ObservableObject { } private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { - try requireCurrentSession(session) + try await validateProfileDeletion(for: session) if cleanPrivatePaykitEndpoints { - await Self.removePrivatePaykitEndpointsBestEffort( + try await Self.removePrivatePaykitEndpoints( context: "PubkyProfileManager.deleteProfile", deferAppSync: true, isSessionCurrent: { self.currentSession == session } ) } try requireCurrentSession(session) let delete = profileDeleter - do { - try await Task.detached { - try await delete(session.publicKey) - }.value - } catch { - guard Self.isMissingBitkitProfileStorageError(error) else { - throw error - } - - Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") - } + try await Task.detached { + try await delete(session.publicKey) + }.value try requireCurrentSession(session) invalidateProfileLoads() @@ -1971,31 +1974,6 @@ class PubkyProfileManager: ObservableObject { return normalized } - nonisolated static func isMissingBitkitProfileStorageError(_ error: Error) -> Bool { - if case .profileNotFound = error as? PubkyServiceError { - return true - } - - let errorText = [ - (error as? AppError)?.debugMessage, - error.localizedDescription, - String(describing: error), - ] - .compactMap { $0?.lowercased() } - - if errorText.contains(where: { $0.contains("404 not found") || $0.contains("directory not found") }) { - return true - } - - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain { - let cocoaCode = CocoaError.Code(rawValue: nsError.code) - return cocoaCode == .fileNoSuchFile || cocoaCode == .fileReadNoSuchFile - } - - return false - } - nonisolated static func resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 32f43eaf1..a5bc73534 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -947,6 +947,7 @@ "profile__edit_delete_section" = "حذف"; "profile__edit_unlink_section" = "إلغاء الربط"; "profile__edit_public_note" = "يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة."; +"profile__delete_active_subscription" = "تمنع الاشتراكات النشطة إزالة هذا الملف الشخصي."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 31498374a..954f732d6 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1063,6 +1063,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tingues en compte que la informació del perfil es desa en fitxers públics."; +"profile__delete_active_subscription" = "Les subscripcions actives impedeixen eliminar aquest perfil."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "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."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 6778721ea..069225dd8 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1168,6 +1168,7 @@ "profile__edit_delete_section" = "SMAZAT"; "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; "profile__edit_public_note" = "Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech."; +"profile__delete_active_subscription" = "Aktivní předplatná brání odstranění tohoto profilu."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "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."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 7ef8df2db..465ab2f90 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1166,6 +1166,7 @@ "profile__edit_delete_section" = "LÖSCHEN"; "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; "profile__edit_public_note" = "Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden."; +"profile__delete_active_subscription" = "Aktive Abonnements verhindern das Entfernen dieses Profils."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 05df3b8eb..7ea74e906 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -964,6 +964,7 @@ "profile__edit_delete_section" = "ΔΙΑΓΡΑΦΗ"; "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; "profile__edit_public_note" = "Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία."; +"profile__delete_active_subscription" = "Οι ενεργές συνδρομές εμποδίζουν την αφαίρεση αυτού του προφίλ."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 528e09fab..44ea68c1a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1181,6 +1181,7 @@ "profile__edit_delete_section" = "DELETE"; "profile__edit_unlink_section" = "UNLINK"; "profile__edit_public_note" = "Please note profile information is stored in public files."; +"profile__delete_active_subscription" = "Active subscriptions prevent removing this profile."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 8c783b9e8..7e656ad89 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1171,6 +1171,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__delete_active_subscription" = "Las suscripciones activas impiden eliminar este perfil."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "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."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 276d55eec..07beacd47 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1080,6 +1080,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__delete_active_subscription" = "Las suscripciones activas impiden eliminar este perfil."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "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."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index eaf695d95..c5324b006 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1179,6 +1179,7 @@ "profile__edit_delete_section" = "SUPPRIMER"; "profile__edit_unlink_section" = "DISSOCIER"; "profile__edit_public_note" = "Veuillez noter que les informations de profil sont stockées dans des fichiers publics."; +"profile__delete_active_subscription" = "Les abonnements actifs empêchent la suppression de ce profil."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "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."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 60a982791..7a0694d57 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1145,6 +1145,7 @@ "profile__edit_delete_section" = "ELIMINA"; "profile__edit_unlink_section" = "SCOLLEGA"; "profile__edit_public_note" = "Tieni presente che le informazioni del profilo sono salvate in file pubblici."; +"profile__delete_active_subscription" = "Gli abbonamenti attivi impediscono la rimozione di questo profilo."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 41e5f45a9..2eb29e67c 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1174,6 +1174,7 @@ "profile__edit_delete_section" = "VERWIJDEREN"; "profile__edit_unlink_section" = "ONTKOPPELEN"; "profile__edit_public_note" = "Let op: profielgegevens worden opgeslagen in openbare bestanden."; +"profile__delete_active_subscription" = "Actieve abonnementen verhinderen het verwijderen van dit profiel."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 07112ac5c..8f03714ae 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1178,6 +1178,7 @@ "profile__edit_delete_section" = "USUWANIE"; "profile__edit_unlink_section" = "ODŁĄCZANIE"; "profile__edit_public_note" = "Pamiętaj, że dane profilu są przechowywane w publicznych plikach."; +"profile__delete_active_subscription" = "Aktywne subskrypcje uniemożliwiają usunięcie tego profilu."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8a10e0f4f..6e7c67ebf 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1179,6 +1179,7 @@ "profile__edit_delete_section" = "EXCLUIR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Observe que as informações do perfil são armazenadas em arquivos públicos."; +"profile__delete_active_subscription" = "As assinaturas ativas impedem a remoção deste perfil."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 7ab668efb..a7d41eea3 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -939,6 +939,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESASSOCIAR"; "profile__edit_public_note" = "Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos."; +"profile__delete_active_subscription" = "As subscrições ativas impedem a remoção deste perfil."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index a1bc9db5f..b8729e9bf 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1164,6 +1164,7 @@ "profile__edit_delete_section" = "УДАЛЕНИЕ"; "profile__edit_unlink_section" = "ОТВЯЗКА"; "profile__edit_public_note" = "Обратите внимание: данные профиля хранятся в публичных файлах."; +"profile__delete_active_subscription" = "Активные подписки не позволяют удалить этот профиль."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 2c98410d5..da4ad3e26 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -356,6 +356,10 @@ enum PubkyService { try await PaykitSdkService.shared.uploadAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) } + static func requireNoActiveSubscriptions(expectedIdentity: String) async throws { + try await PaykitSdkService.shared.requireNoActiveSubscriptions(expectedIdentity: expectedIdentity) + } + static func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await PaykitSdkService.shared.deletePaykitProfile(expectedIdentity: expectedIdentity) } @@ -393,6 +397,10 @@ enum PubkyService { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } + static func removeContactsForProfile(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.removeContactsForProfile(publicKeys: publicKeys) + } + static func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { try await PaykitSdkService.shared.removeContacts(publicKeys: publicKeys) } @@ -818,11 +826,35 @@ actor PaykitSdkService { return record.uri } + func requireNoActiveSubscriptions(expectedIdentity: String) async throws { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + try await Self.requireNoActiveSubscriptions(in: sdk) + } + } + + private static func requireNoActiveSubscriptions(in sdk: PaykitSdk) async throws { + let requests = try await sdk.paymentRequests() + try Task.checkCancellation() + let now = Date() + let activeSubscriptions = requests.filter { + $0.state == .activeRecurring && + ($0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate).map { $0 > now } ?? true) + } + guard !activeSubscriptions.isEmpty else { return } + let endDates = activeSubscriptions.compactMap { + $0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate) + } + let latestEndDate = endDates.count == activeSubscriptions.count ? endDates.max() : nil + throw PubkyServiceError.activeSubscription(endsAt: latestEndDate) + } + func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await withStateRevisionTracking { sdk in if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } + try await Self.requireNoActiveSubscriptions(in: sdk) guard let publicKey = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive } guard let current = try await sdk.fetchPaykitProfile(publicKey: publicKey) else { return } try await sdk.deletePaykitProfile(expectedRevision: current.revision) @@ -943,6 +975,14 @@ actor PaykitSdkService { } } + func removeContactsForProfile(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await withStateRevisionTracking { sdk in + try await Self.requireNoActiveSubscriptions(in: sdk) + guard !publicKeys.isEmpty else { return [] } + return try await sdk.removeContactsAndBlockPeers(publicKeys: publicKeys) + } + } + func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { guard !publicKeys.isEmpty else { return [] } return try await withStateRevisionTracking { sdk in diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index e9dbe433e..d85df61f5 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -160,6 +160,12 @@ struct EditProfileView: View { try await pubkyProfile.performProfileDeletion { do { try await performDeleteProfile(session: session) + } catch let PubkyServiceError.activeSubscription(endsAt) { + guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } + let description = endsAt.map { + t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) + } + app.toast(type: .error, title: t("profile__delete_active_subscription"), description: description) } catch { guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") @@ -174,16 +180,27 @@ struct EditProfileView: View { } private func performDeleteProfile(session: PubkyProfileManager.SignedInSession) async throws { + try await Self.performDeleteProfile(session: session, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { + try await PubkyProfileManager.removePrivatePaykitEndpoints( + context: "EditProfileView.deleteProfile", deferAppSync: true, + isSessionCurrent: { pubkyProfile.currentSession == session } + ) + } + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + + static func performDeleteProfile( + session: PubkyProfileManager.SignedInSession, + pubkyProfile: PubkyProfileManager, + contactsManager: ContactsManager, + removePrivateEndpoints: () async throws -> Void + ) async throws { + try await pubkyProfile.validateProfileDeletion(for: session) + try await removePrivateEndpoints() try pubkyProfile.requireCurrentSession(session) - await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort( - context: "EditProfileView.deleteProfile", deferAppSync: true, - isSessionCurrent: { pubkyProfile.currentSession == session } - ) - try pubkyProfile.requireCurrentSession(session) - await contactsManager.deleteAllContactsBestEffort() + try await contactsManager.deleteAllContacts() try pubkyProfile.requireCurrentSession(session) try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } private func disconnectAfterFailedDelete() async { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index b137285e9..bc50bf841 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -2248,6 +2248,28 @@ final class ContactsManagerTests: XCTestCase { XCTAssertTrue(manager.contacts.isEmpty) } + func testDeleteAllContactsPreservesEvidenceWhenTheContactReadFails() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let contact = makeContact(publicKey: contactProfileKey) + ContactsManager.restoreContactProfileOverrides([contact.publicKey: PubkyProfileData.from(profile: contact.profile)]) + let unavailable = AppError(message: "App Error", debugMessage: "Fetch failed: 404 missing session") + let manager = ContactsManager(contactRecords: { throw unavailable }, removeContactRecords: { _ in + XCTFail("An unavailable read must not authorize a removal") + return [] + }) + manager.contacts = [contact] + + do { + try await manager.deleteAllContacts() + XCTFail("An unavailable read is not an empty contact list") + } catch { + XCTAssertEqual(error.localizedDescription, unavailable.localizedDescription) + } + + XCTAssertEqual(manager.contacts, [contact]) + XCTAssertNotNil(ContactsManager.backupContactProfileOverrides()?[contact.publicKey]) + } + func testDeleteAllContactsPreservesContactsNotRemovedByTheBulkOperation() async throws { snapshotAppDefaults("pubkyContactProfileOverrides") let removed = contactRecord(key: contactProfileKey, name: "Removed") diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index ec32557e4..56d261eed 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -469,6 +469,114 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(sdk.writes, ["upload"]) } + func testProfileDeletionRefusesActiveSubscriptionsInEitherRoleWithoutContacts() async throws { + for role in [PaymentRequestLocalRole.payer, .payee] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + var request = try paymentRequestRecord(appId: "another-app") + request.localRole = role + request.state = .activeRecurring + sdk.rawPaymentRequests = { [request] } + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + try await service.deletePaykitProfile(expectedIdentity: identityA) + XCTFail("An active subscription must prevent profile deletion even without a contact") + } catch PubkyServiceError.activeSubscription {} + XCTAssertTrue(sdk.writes.isEmpty) + } + } + + func testProfileCleanupChecksAllSubscriptionsBeforeRemovingAnyContact() async throws { + for endsAt in [nil, "invalid", "9999-01-01T00:00:00Z", "2000-01-01T00:00:00Z"] as [String?] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + var request = try paymentRequestRecord(appId: "another-app") + request.state = .activeRecurring + request.terms?.recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "1999-01-01T00:00:00Z", anchor: "1999-01-01T00:00:00Z", endsAt: endsAt + ) + sdk.rawPaymentRequests = { [request] } + let service = PaykitSdkService(sdkFactory: { sdk }) + let blocked = endsAt != "2000-01-01T00:00:00Z" + let operations: [() async throws -> Void] = [ + { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, + { _ = try await service.removeContactsForProfile(publicKeys: [identityB]) }, + { _ = try await service.removeContactsForProfile(publicKeys: []) }, + { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + ] + for operation in operations { + do { + try await operation() + XCTAssertFalse(blocked) + } catch let PubkyServiceError.activeSubscription(actualEnd) { + XCTAssertTrue(blocked) + XCTAssertEqual(actualEnd, endsAt.flatMap(PaykitPaymentRequest.parseDate)) + } + } + XCTAssertEqual(sdk.writes, blocked ? [] : ["remove-contacts:1", "delete-profile:test"]) + } + } + + func testProfileRemovalOnlyReportsAnEndWhenEveryBlockingSubscriptionHasOne() async throws { + let earlier = "2999-01-01T00:00:00Z" + let later = "3000-01-01T00:00:00Z" + let cases: [(ends: [String?], expected: String?)] = [ + ([earlier, later], later), + ([later, earlier], later), + ([earlier, nil], nil), + (["invalid", later], nil), + (["2000-01-01T00:00:00Z", later], later), + ] + for testCase in cases { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let requests = try testCase.ends.map { endsAt in + var request = try paymentRequestRecord(appId: "another-app") + request.state = .activeRecurring + request.terms?.recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "1999-01-01T00:00:00Z", anchor: "1999-01-01T00:00:00Z", endsAt: endsAt + ) + return request + } + sdk.rawPaymentRequests = { requests } + let service = PaykitSdkService(sdkFactory: { sdk }) + do { + try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) + XCTFail("Every case contains a blocking subscription") + } catch let PubkyServiceError.activeSubscription(endsAt) { + XCTAssertEqual(endsAt, testCase.expected.flatMap(PaykitPaymentRequest.parseDate)) + } + XCTAssertTrue(sdk.writes.isEmpty) + } + } + + func testSubscriptionReadFailureAndCancellationNeverAuthorizeProfileCleanup() async throws { + for failure: Error in [sharedStateBusy, CancellationError()] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + sdk.rawPaymentRequests = { throw failure } + let service = PaykitSdkService(sdkFactory: { sdk }) + let operations: [() async throws -> Void] = [ + { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, + { _ = try await service.removeContactsForProfile(publicKeys: [contactKey]) }, + { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + ] + for operation in operations { + do { + try await operation() + XCTFail("An unavailable subscription read must stop cleanup") + } catch { + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + } + XCTAssertTrue(sdk.writes.isEmpty) + } + } + func testProfileDeletionChecksItsOwnerBeforeReadingOrRemovingTheProfile() async throws { for signedIn in [identityA, identityB, nil] { let sdk = IdentitySwitchingSdk(noPointer: .init()) @@ -1005,6 +1113,8 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { PaymentRequestRead(identityStatus: nil, paymentRequests: []) } + var rawPaymentRequests: () async throws -> [PaymentRequestRecord] = { [] } + var schedulingRead: () async throws -> Paykit.PrivateMessageSchedulingSnapshot = { Paykit.PrivateMessageSchedulingSnapshot(linkedPeers: [], pendingOutboundCounterparties: []) } @@ -1080,6 +1190,10 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } } + override func paymentRequests() async throws -> [PaymentRequestRecord] { + try await rawPaymentRequests() + } + override func readPaymentRequests(counterparty: String, expectedIdentity: String?) async throws -> PaymentRequestRead { try await paymentRequestRead(counterparty, expectedIdentity) } @@ -1149,6 +1263,11 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { return [] } + override func removeContactsAndBlockPeers(publicKeys: [String]) async throws -> [ContactRecord] { + recordWrite("remove-contacts:\(publicKeys.count)") + return publicKeys.map { Self.record(publicKey: $0, label: nil) } + } + override func contactRecord(publicKey: String) async throws -> ContactRecord? { lock.withLock { recordedContactReads += 1 } return Self.record(publicKey: publicKey, label: "Contact") diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 8ea21e4ad..42da089e3 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -12,6 +12,89 @@ final class PubkyProfileManagerTests: XCTestCase { case signOut } + @MainActor + func testActiveSubscriptionStopsProfileRemovalBeforePrivateOrContactCleanup() async throws { + try await withEmptyIdentityStorage { + for entry in ["screen", "manager"] { + let key = "pubky" + String(repeating: "y", count: 52) + let manager = PubkyProfileManager(profileDeletionValidator: { expectedIdentity in + XCTAssertEqual(expectedIdentity, key) + throw PubkyServiceError.activeSubscription(endsAt: nil) + }, profileDeleter: { _ in + XCTFail("An active subscription must preserve the profile") + }) + manager.publicKey = key + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + let contacts = ContactsManager(contactRecords: { + XCTFail("An active subscription must stop before contact cleanup") + return [] + }) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + do { + if entry == "screen" { + try await manager.performProfileDeletion { + try await EditProfileView.performDeleteProfile( + session: session, pubkyProfile: manager, contactsManager: contacts + ) { XCTFail("An active subscription must preserve private endpoints") } + } + } else { + try await manager.deleteProfile() + } + XCTFail("Expected subscription refusal") + } catch PubkyServiceError.activeSubscription {} + XCTAssertEqual(manager.currentSession, session) + XCTAssertFalse(manager.isDeletingProfile) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + } + } + } + + @MainActor + func testProfileRemovalStopsBeforeDeletingWhenCleanupIsIncomplete() async throws { + snapshotAppDefaultsDomain() + for failure in ["private endpoints", "contacts"] { + let key = "pubky" + String(repeating: "y", count: 52) + let originalError = KeychainError.failedToDelete + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in + XCTFail("Incomplete cleanup must not delete the profile") + throw PublicPaykitError.publicationFailed + }) + manager.publicKey = key + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + let contact = Bitkit.PubkyProfile.placeholder(publicKey: key) + ContactsManager.restoreContactProfileOverrides([key: PubkyProfileData.from(profile: contact)]) + let contacts = ContactsManager(contactRecords: { + XCTAssertEqual(failure, "contacts", "Private withdrawal failure must stop before contact removal") + throw originalError + }) + contacts.contacts = [PubkyContact(publicKey: key, profile: contact)] + + do { + try await manager.performProfileDeletion { + try await EditProfileView.performDeleteProfile( + session: session, pubkyProfile: manager, contactsManager: contacts + ) { + if failure == "private endpoints" { + throw originalError + } + } + } + XCTFail("Incomplete cleanup must fail") + } catch { + XCTAssertTrue(error is KeychainError, "Preserve the cleanup failure, got \(error)") + } + + XCTAssertEqual(contacts.contacts.map(\.publicKey), [key]) + XCTAssertEqual(Set(ContactsManager.backupContactProfileOverrides()?.keys ?? [:].keys), [key]) + XCTAssertEqual(manager.currentSession, session) + XCTAssertFalse(manager.isDeletingProfile) + } + } + @MainActor func testProfileDeletionPausesAutomaticWorkWithoutEndingTheSession() async throws { for outcome in ["success", "failure", "cancellation"] { @@ -201,7 +284,7 @@ final class PubkyProfileManagerTests: XCTestCase { let started = expectation(description: "Profile delete admitted") let (resume, continuation) = AsyncStream.makeStream() defer { continuation.finish() } - let manager = PubkyProfileManager(profileDeleter: { expectedIdentity in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { expectedIdentity in XCTAssertEqual(expectedIdentity, original) started.fulfill() for await _ in resume {} @@ -280,6 +363,43 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testProfileDeletionRequiresAcknowledgedCompletionBeforeSignOut() async throws { + try await withEmptyIdentityStorage { + let errors: [NSError?] = [ + nil, + NSError(domain: "ProfileDeleteFixture", code: 404, + userInfo: [NSLocalizedDescriptionKey: "Delete failed: HTTP 404 Not Found at session endpoint"]), + NSError(domain: NSCocoaErrorDomain, code: CocoaError.fileReadNoSuchFile.rawValue), + ] + for failure in errors { + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in + if let failure { + throw failure + } + }) + manager.publicKey = "pubky" + String(repeating: "y", count: 52) + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + + do { + try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) + XCTAssertNil(failure, "An unavailable profile does not confirm its deletion") + } catch { + XCTAssertEqual((error as NSError).domain, failure?.domain) + XCTAssertEqual((error as NSError).code, failure?.code) + } + + XCTAssertEqual(manager.currentSession, failure == nil ? nil : session) + XCTAssertEqual(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey), failure != nil) + XCTAssertEqual(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey), failure != nil) + XCTAssertFalse(manager.isDeletingProfile) + } + } + } + @MainActor func testAutomaticPaykitSessionAppearsWhenTheSessionChangeEndsAndIsAnnounced() async throws { try await withEmptyIdentityStorage { @@ -308,7 +428,7 @@ final class PubkyProfileManagerTests: XCTestCase { try await withEmptyIdentityStorage { let secret = String(repeating: "01", count: 32) let original = try PubkyProfileManager.publicKeyFromSecretKey(secret) - let manager = PubkyProfileManager(profileDeleter: { _ in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in XCTFail("A stale deletion must not reach profile storage") }) manager.publicKey = original @@ -3560,24 +3680,6 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(resolved) } - func testIsMissingBitkitProfileStorageErrorRecognizes404DeleteFailure() { - let error = AppError( - message: "App Error", - debugMessage: #"BitkitCore.PubkyError.WriteFailed(reason: "delete failed: Request failed: Server responded with an error: 404 Not Found - Not Found")"# - ) - - XCTAssertTrue(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) - } - - func testIsMissingBitkitProfileStorageErrorRejectsNonMissingErrors() { - let error = AppError( - message: "App Error", - debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error")"# - ) - - XCTAssertFalse(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) - } - // MARK: - Session backup state func testSnapshotSessionBackupStatePrefersLocalSeedOverSessionSecret() throws { diff --git a/changelog.d/next/1124.fixed.md b/changelog.d/next/1124.fixed.md new file mode 100644 index 000000000..64a4e1001 --- /dev/null +++ b/changelog.d/next/1124.fixed.md @@ -0,0 +1 @@ +Preserve session and contact data after incomplete profile cleanup, and explain when active subscriptions prevent profile removal. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml new file mode 100644 index 000000000..9b7a6747e --- /dev/null +++ b/journeys/profile/profile-cleanup-failure.xml @@ -0,0 +1,27 @@ + + + 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 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 refusal; show the latest expiry only when all blocking subscriptions have known finite ends, and no date for open-ended or unknown terms + 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 + 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 + +