import Foundation import Contacts // MARK: - Contacts data models struct ContactListItem: Codable, Equatable, Sendable { let id: String let name: String let organization: String let modifiedAt: String } struct ContactEmailLabelValue: Codable, Equatable, Sendable { let label: String let value: String } struct ContactPhoneLabelValue: Codable, Equatable, Sendable { let label: String let value: String } struct ContactDetailItem: Codable, Equatable, Sendable { let id: String let name: String let firstName: String let lastName: String let organization: String let jobTitle: String let emails: [ContactEmailLabelValue] let phones: [ContactPhoneLabelValue] let modifiedAt: String } struct ContactCreateResult: Codable, Equatable, Sendable { let id: String let name: String let organization: String } // MARK: - Contacts provider errors enum ContactsProviderError: Error, Equatable, Sendable { case permissionRequired case permissionDenied case unavailable(String) case invalidRequest(String) case notFound(String) } // MARK: - Contact provider protocols protocol ContactsAuthorizationProviding: Sendable { func authorizationStatus() -> ContactsAuthorizationStatus func requestAccess() throws -> Bool } enum ContactsAuthorizationStatus: String, Equatable, Sendable { case authorized case notDetermined case denied case restricted case unknown } protocol ContactsSearchProviding: Sendable { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] } protocol ContactsReadProviding: Sendable { func readContact(id: String) throws -> ContactDetailItem } protocol ContactsCreateProviding: Sendable { func createContact( firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue? ) throws -> ContactCreateResult } protocol FullContactsProviding: ContactsSearchProviding, ContactsReadProviding, ContactsCreateProviding {} // MARK: - Production Contacts providers private func displayNameFromFetchedParts(givenName: String, familyName: String) -> String { let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines) let squashed = combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ") return squashed } struct ContactsSearchProvider: ContactsSearchProviding { private func requireAuthorized() throws { let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .authorized: return case .denied, .restricted, .notDetermined: throw ContactsProviderError.permissionRequired @unknown default: throw ContactsProviderError.permissionRequired } } func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { try requireAuthorized() let store = CNContactStore() let keys: [CNKeyDescriptor] = [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactOrganizationNameKey as CNKeyDescriptor ] let fetchRequest = CNContactFetchRequest(keysToFetch: keys) var items: [ContactListItem] = [] let q = query?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let shouldFilter = !(q?.isEmpty ?? true) do { try store.enumerateContacts(with: fetchRequest) { contact, stop in // Production crash fix: the formatter accesses unfetched // properties like middleName (CNPropertyNotFetchedException is ObjC exception // and is uncatchable in Swift). Construct deterministically from only fetched keys. let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName) let org = contact.organizationName if shouldFilter, let lowerQ = q { let haystack = "\(fullName)\n\(org)".lowercased() if !haystack.contains(lowerQ) { return } } // Deterministic: do not leak current time; nil modification date yields stable empty string let modifiedStr: String = "" items.append(ContactListItem( id: contact.identifier, name: fullName, organization: org, modifiedAt: modifiedStr )) if items.count >= limit { stop.pointee = true } } } catch let err as NSError { // Permission or other failure if err.domain == CNErrorDomain { throw ContactsProviderError.unavailable(err.localizedDescription) } throw ContactsProviderError.unavailable(err.localizedDescription) } // Deterministic: sorted by name then org then id items.sort { if $0.name != $1.name { return $0.name < $1.name } if $0.organization != $1.organization { return $0.organization < $1.organization } return $0.id < $1.id } if items.count > limit { return Array(items.prefix(limit)) } return items } } struct ContactsReadProvider: ContactsReadProviding { private func requireAuthorized() throws { let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .authorized: return case .denied, .restricted, .notDetermined: throw ContactsProviderError.permissionRequired @unknown default: throw ContactsProviderError.permissionRequired } } func readContact(id: String) throws -> ContactDetailItem { guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ContactsProviderError.invalidRequest("Missing required argument: id") } try requireAuthorized() let store = CNContactStore() let keys: [CNKeyDescriptor] = [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactOrganizationNameKey as CNKeyDescriptor, CNContactJobTitleKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor ] do { let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys) // Same crash root cause as search: the system formatter can touch unfetched keys. // Use only the keys we fetched to avoid ObjC CNPropertyNotFetchedException. let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName) // Deterministic: nil modification date yields stable empty string, not current time let modifiedStr = "" let emails = contact.emailAddresses.map { labeled in ContactEmailLabelValue( label: CNLabeledValue.localizedString(forLabel: labeled.label ?? ""), value: labeled.value as String ) } let phones = contact.phoneNumbers.map { labeled in ContactPhoneLabelValue( label: CNLabeledValue.localizedString(forLabel: labeled.label ?? ""), value: labeled.value.stringValue ) } return ContactDetailItem( id: contact.identifier, name: fullName, firstName: contact.givenName, lastName: contact.familyName, organization: contact.organizationName, jobTitle: contact.jobTitle, emails: emails, phones: phones, modifiedAt: modifiedStr ) } catch let err as CNError { if err.code == .recordDoesNotExist { throw ContactsProviderError.notFound("Contact not found: \(id)") } throw ContactsProviderError.unavailable(err.localizedDescription) } catch let err as ContactsProviderError { throw err } catch { throw ContactsProviderError.unavailable(error.localizedDescription) } } } struct ContactsCreateProvider: ContactsCreateProviding { private func requireAuthorized() throws { let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .authorized: return case .denied, .restricted, .notDetermined: throw ContactsProviderError.permissionRequired @unknown default: throw ContactsProviderError.permissionRequired } } func createContact( firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue? ) throws -> ContactCreateResult { try requireAuthorized() let mutable = CNMutableContact() mutable.givenName = firstName ?? "" mutable.familyName = lastName ?? "" mutable.organizationName = organization ?? "" mutable.jobTitle = jobTitle ?? "" if let n = note { mutable.note = n } if let em = email { mutable.emailAddresses = [CNLabeledValue(label: em.label.isEmpty ? CNLabelWork : em.label, value: em.value as NSString)] } if let ph = phone { mutable.phoneNumbers = [CNLabeledValue(label: ph.label.isEmpty ? CNLabelPhoneNumberMobile : ph.label, value: CNPhoneNumber(stringValue: ph.value))] } let store = CNContactStore() let saveRequest = CNSaveRequest() saveRequest.add(mutable, toContainerWithIdentifier: nil) do { try store.execute(saveRequest) } catch let err as NSError { throw ContactsProviderError.unavailable(err.localizedDescription) } // CNMutableContact doesn't trigger key-fetch checks; safe deterministic construction still. // Prefer only locally available strings – avoids future formatter regressions. let fullName = displayNameFromFetchedParts(givenName: mutable.givenName, familyName: mutable.familyName) return ContactCreateResult( id: mutable.identifier, name: fullName, organization: organization ?? "" ) } }