This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
610 lines
18 KiB
Markdown
610 lines
18 KiB
Markdown
---
|
|
name: axiom-contacts-ref
|
|
description: Use when needing Contacts API details — CNContactStore, CNMutableContact, CNSaveRequest, CNContactFormatter, CNContactVCardSerialization, CNContactPickerViewController, ContactAccessButton, contactAccessPicker, ContactProvider extension, CNChangeHistoryFetchRequest, contact key descriptors, and CNError codes
|
|
license: MIT
|
|
---
|
|
|
|
# Contacts API Reference
|
|
|
|
## Overview
|
|
|
|
The Contacts framework provides programmatic access to the system contact database. ContactsUI provides system view controllers for contact selection and display. ContactProvider enables apps to expose their own contacts to the system.
|
|
|
|
**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+
|
|
|
|
---
|
|
|
|
# Part 1: CNContactStore
|
|
|
|
The primary gateway for contact data. "Fetch methods perform I/O — avoid using the main thread."
|
|
|
|
## Authorization
|
|
|
|
```swift
|
|
// Check status (static method)
|
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
|
// Returns: .notDetermined, .restricted, .denied, .authorized, .limited (iOS 18+)
|
|
|
|
// Request access
|
|
let store = CNContactStore()
|
|
try await store.requestAccess(for: .contacts) // Returns Bool
|
|
```
|
|
|
|
**Info.plist required**: `NSContactsUsageDescription` (crash without it).
|
|
|
|
**Special entitlement**: `com.apple.developer.contacts.notes` — required to read/write `note` field. Requires Apple approval.
|
|
|
|
## Fetching Contacts
|
|
|
|
```swift
|
|
// Single contact by identifier
|
|
let contact = try store.unifiedContact(
|
|
withIdentifier: identifier,
|
|
keysToFetch: keys
|
|
)
|
|
|
|
// Search by predicate
|
|
let contacts = try store.unifiedContacts(
|
|
matching: predicate,
|
|
keysToFetch: keys
|
|
)
|
|
|
|
// Current user's card
|
|
let me = try store.unifiedMeContact(withKeysToFetch: keys)
|
|
|
|
// Memory-efficient enumeration
|
|
let request = CNContactFetchRequest(keysToFetch: keys)
|
|
request.predicate = predicate // Optional filter
|
|
request.sortOrder = .userDefault // .none, .givenName, .familyName, .userDefault
|
|
try store.enumerateContacts(with: request) { contact, stop in
|
|
// stop.pointee = true to break early
|
|
}
|
|
```
|
|
|
|
## Built-in Predicates
|
|
|
|
```swift
|
|
CNContact.predicateForContacts(matchingName: "John")
|
|
CNContact.predicateForContacts(matchingEmailAddress: "john@example.com")
|
|
CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: "+1555..."))
|
|
CNContact.predicateForContacts(withIdentifiers: [id1, id2])
|
|
CNContact.predicateForContactsInGroup(withIdentifier: groupId)
|
|
CNContact.predicateForContactsInContainer(withIdentifier: containerId)
|
|
```
|
|
|
|
## Containers and Groups
|
|
|
|
```swift
|
|
store.containers(matching: predicate) // [CNContainer]
|
|
store.groups(matching: predicate) // [CNGroup]
|
|
store.defaultContainerIdentifier // String (property, not method)
|
|
```
|
|
|
|
**CNContainer types**: `.local`, `.exchange`, `.cardDAV`, `.unassigned`
|
|
|
|
## Change Tracking
|
|
|
|
```swift
|
|
store.currentHistoryToken // Data? — save for incremental sync
|
|
```
|
|
|
|
## Change Notification
|
|
|
|
```swift
|
|
NotificationCenter.default.addObserver(
|
|
forName: .CNContactStoreDidChange, object: nil, queue: .main
|
|
) { _ in
|
|
// Refetch visible contacts
|
|
}
|
|
```
|
|
|
|
## Save Operations
|
|
|
|
```swift
|
|
let saveRequest = CNSaveRequest()
|
|
saveRequest.add(contact, toContainerWithIdentifier: nil) // nil = default
|
|
saveRequest.update(contact)
|
|
saveRequest.delete(contact.mutableCopy() as! CNMutableContact)
|
|
try store.execute(saveRequest)
|
|
```
|
|
|
|
---
|
|
|
|
# Part 2: CNContact Key Descriptors
|
|
|
|
You MUST specify which properties to fetch. Accessing an unfetched property throws `CNContactPropertyNotFetchedException`.
|
|
|
|
## Common Key Constants
|
|
|
|
| Key | Property |
|
|
|-----|----------|
|
|
| `CNContactIdentifierKey` | `identifier` |
|
|
| `CNContactGivenNameKey` | `givenName` |
|
|
| `CNContactFamilyNameKey` | `familyName` |
|
|
| `CNContactMiddleNameKey` | `middleName` |
|
|
| `CNContactNamePrefixKey` | `namePrefix` |
|
|
| `CNContactNameSuffixKey` | `nameSuffix` |
|
|
| `CNContactNicknameKey` | `nickname` |
|
|
| `CNContactOrganizationNameKey` | `organizationName` |
|
|
| `CNContactJobTitleKey` | `jobTitle` |
|
|
| `CNContactDepartmentNameKey` | `departmentName` |
|
|
| `CNContactPhoneNumbersKey` | `phoneNumbers` |
|
|
| `CNContactEmailAddressesKey` | `emailAddresses` |
|
|
| `CNContactPostalAddressesKey` | `postalAddresses` |
|
|
| `CNContactUrlAddressesKey` | `urlAddresses` |
|
|
| `CNContactSocialProfilesKey` | `socialProfiles` |
|
|
| `CNContactInstantMessageAddressesKey` | `instantMessageAddresses` |
|
|
| `CNContactBirthdayKey` | `birthday` |
|
|
| `CNContactNonGregorianBirthdayKey` | `nonGregorianBirthday` |
|
|
| `CNContactDatesKey` | `dates` |
|
|
| `CNContactNoteKey` | `note` (requires entitlement) |
|
|
| `CNContactImageDataKey` | `imageData` |
|
|
| `CNContactThumbnailImageDataKey` | `thumbnailImageData` |
|
|
| `CNContactImageDataAvailableKey` | `imageDataAvailable` |
|
|
| `CNContactRelationsKey` | `contactRelations` |
|
|
| `CNContactTypeKey` | `contactType` (.person, .organization) |
|
|
|
|
## Convenience Descriptors
|
|
|
|
```swift
|
|
// All keys needed for name display (locale-aware)
|
|
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
|
|
CNContactFormatter.descriptorForRequiredKeys(for: .phoneticFullName)
|
|
|
|
// All keys needed for vCard export
|
|
CNContactVCardSerialization.descriptorForRequiredKeys()
|
|
```
|
|
|
|
**Always prefer formatter descriptors** over manual key lists for name display.
|
|
|
|
---
|
|
|
|
# Part 3: CNMutableContact
|
|
|
|
Mutable subclass of `CNContact`. **Not thread-safe** — use immutable `CNContact` for cross-thread access.
|
|
|
|
## Creating a Contact
|
|
|
|
```swift
|
|
let contact = CNMutableContact()
|
|
contact.givenName = "Jane"
|
|
contact.familyName = "Appleseed"
|
|
contact.organizationName = "Apple Inc."
|
|
contact.jobTitle = "Engineer"
|
|
|
|
// Phone numbers
|
|
contact.phoneNumbers = [
|
|
CNLabeledValue(label: CNLabelPhoneNumberMobile,
|
|
value: CNPhoneNumber(stringValue: "+15551234567")),
|
|
CNLabeledValue(label: CNLabelWork,
|
|
value: CNPhoneNumber(stringValue: "+15559876543"))
|
|
]
|
|
|
|
// Email addresses
|
|
contact.emailAddresses = [
|
|
CNLabeledValue(label: CNLabelHome, value: "jane@example.com" as NSString),
|
|
CNLabeledValue(label: CNLabelWork, value: "jane@apple.com" as NSString)
|
|
]
|
|
|
|
// Postal addresses
|
|
let address = CNMutablePostalAddress()
|
|
address.street = "1 Apple Park Way"
|
|
address.city = "Cupertino"
|
|
address.state = "CA"
|
|
address.postalCode = "95014"
|
|
address.country = "United States"
|
|
contact.postalAddresses = [CNLabeledValue(label: CNLabelWork, value: address)]
|
|
|
|
// Birthday
|
|
contact.birthday = DateComponents(year: 1990, month: 6, day: 15)
|
|
|
|
// Photo
|
|
contact.imageData = imageData
|
|
```
|
|
|
|
## Removing Values
|
|
|
|
Set strings/arrays to empty, other properties to `nil`.
|
|
|
|
## Constraint
|
|
|
|
"You may modify only those properties whose values you fetched from the contacts database."
|
|
|
|
---
|
|
|
|
# Part 4: CNSaveRequest
|
|
|
|
Batch operations for contacts, groups, and subgroups.
|
|
|
|
**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+ (no watchOS)
|
|
|
|
## Contact Operations
|
|
|
|
```swift
|
|
let request = CNSaveRequest()
|
|
request.add(contact, toContainerWithIdentifier: containerId) // nil = default
|
|
request.update(contact)
|
|
request.delete(contact)
|
|
```
|
|
|
|
## Group Operations
|
|
|
|
```swift
|
|
request.add(group, toContainerWithIdentifier: containerId)
|
|
request.update(group)
|
|
request.delete(group)
|
|
request.addMember(contact, to: group)
|
|
request.removeMember(contact, from: group)
|
|
request.addSubgroup(subgroup, to: parentGroup)
|
|
request.removeSubgroup(subgroup, from: parentGroup)
|
|
```
|
|
|
|
## Properties
|
|
|
|
| Property | Type | Notes |
|
|
|----------|------|-------|
|
|
| `shouldRefetchContacts` | `Bool` | Refetch added/updated contacts post-execution |
|
|
| `transactionAuthor` | `String?` | Identifies who made the change (for change history filtering) |
|
|
|
|
## Execution
|
|
|
|
```swift
|
|
try store.execute(request)
|
|
```
|
|
|
|
**Concurrency**: "Last change wins" for overlapping concurrent changes.
|
|
|
|
---
|
|
|
|
# Part 5: CNContactFormatter
|
|
|
|
Locale-aware name formatting.
|
|
|
|
```swift
|
|
let formatter = CNContactFormatter()
|
|
|
|
// Format name
|
|
let name = formatter.string(from: contact) // String?
|
|
let name = CNContactFormatter.string(from: contact, style: .fullName)
|
|
|
|
// Attributed string variants
|
|
let attributed = formatter.attributedString(from: contact)
|
|
|
|
// Locale information
|
|
let order = CNContactFormatter.nameOrder(for: contact) // .givenNameFirst, .familyNameFirst
|
|
let delimiter = CNContactFormatter.delimiter(for: contact) // Locale-appropriate separator
|
|
```
|
|
|
|
## Styles
|
|
|
|
| Style | Example |
|
|
|-------|---------|
|
|
| `.fullName` | "Jane Appleseed" or "Appleseed Jane" (per locale) |
|
|
| `.phoneticFullName` | Phonetic representation |
|
|
|
|
---
|
|
|
|
# Part 6: CNContactVCardSerialization
|
|
|
|
```swift
|
|
// Export contacts to vCard data
|
|
let data = try CNContactVCardSerialization.data(with: contacts)
|
|
|
|
// Import contacts from vCard data
|
|
let contacts = try CNContactVCardSerialization.contacts(with: data)
|
|
|
|
// Required keys for export
|
|
let keys = CNContactVCardSerialization.descriptorForRequiredKeys()
|
|
```
|
|
|
|
---
|
|
|
|
# Part 7: ContactsUI
|
|
|
|
## CNContactPickerViewController (iOS 9+)
|
|
|
|
Lets users pick contacts **without requiring app-level authorization**. App receives one-time snapshot.
|
|
|
|
```swift
|
|
let picker = CNContactPickerViewController()
|
|
picker.delegate = self
|
|
picker.displayedPropertyKeys = [CNContactPhoneNumbersKey, CNContactEmailAddressesKey]
|
|
present(picker, animated: true)
|
|
```
|
|
|
|
### Predicates (set BEFORE presentation)
|
|
|
|
```swift
|
|
// Which contacts are selectable
|
|
picker.predicateForEnablingContact = NSPredicate(
|
|
format: "phoneNumbers.@count > 0"
|
|
)
|
|
|
|
// Auto-select whole contact (skip property selection)
|
|
picker.predicateForSelectionOfContact = NSPredicate(
|
|
format: "emailAddresses.@count > 0"
|
|
)
|
|
|
|
// Which properties can be selected individually
|
|
picker.predicateForSelectionOfProperty = NSPredicate(
|
|
format: "key == 'phoneNumbers'"
|
|
)
|
|
```
|
|
|
|
**Gotcha**: Changing predicates only takes effect before the view is presented.
|
|
|
|
### Delegate (CNContactPickerDelegate)
|
|
|
|
```swift
|
|
func contactPicker(_ picker: CNContactPickerViewController,
|
|
didSelect contact: CNContact) { }
|
|
func contactPicker(_ picker: CNContactPickerViewController,
|
|
didSelect contacts: [CNContact]) { } // Multi-selection
|
|
func contactPicker(_ picker: CNContactPickerViewController,
|
|
didSelect contactProperty: CNContactProperty) { }
|
|
func contactPickerDidCancel(_ picker: CNContactPickerViewController) { }
|
|
```
|
|
|
|
## CNContactViewController (iOS 9+)
|
|
|
|
Display a single contact with three initialization modes:
|
|
|
|
```swift
|
|
// Existing contact
|
|
let vc = CNContactViewController(for: contact)
|
|
|
|
// Unknown contact (partial data)
|
|
let vc = CNContactViewController(forUnknownContact: partialContact)
|
|
|
|
// New contact
|
|
let vc = CNContactViewController(forNewContact: nil)
|
|
|
|
// Display mode
|
|
vc.allowsEditing = true
|
|
vc.allowsActions = true // Call, message, email buttons
|
|
vc.displayedPropertyKeys = [CNContactPhoneNumbersKey]
|
|
vc.highlightProperty(withKey: CNContactPhoneNumbersKey, identifier: nil)
|
|
```
|
|
|
|
---
|
|
|
|
# Part 8: Contact Access Button (iOS 18+)
|
|
|
|
SwiftUI component for privacy-conscious contact access.
|
|
|
|
```swift
|
|
ContactAccessButton(queryString: searchText) { identifiers in
|
|
let contacts = await fetchContacts(withIdentifiers: identifiers)
|
|
}
|
|
```
|
|
|
|
### Caption Options
|
|
|
|
| Value | Shows |
|
|
|-------|-------|
|
|
| `.defaultText` | Default text |
|
|
| `.email` | Email address |
|
|
| `.phone` | Phone number |
|
|
|
|
### Modifiers
|
|
|
|
```swift
|
|
.font(.system(weight: .bold))
|
|
.foregroundStyle(.gray)
|
|
.tint(.green)
|
|
.contactAccessButtonCaption(.phone)
|
|
.contactAccessButtonStyle(ContactAccessButton.Style(imageWidth: 30))
|
|
```
|
|
|
|
### Security
|
|
|
|
Button only grants access when:
|
|
- **Legible**: Sufficient contrast between text and background
|
|
- **Unobstructed**: Entire button visible, not clipped
|
|
- **Validated tap**: Real user interaction, not simulated
|
|
|
|
---
|
|
|
|
# Part 9: contactAccessPicker (iOS 18+)
|
|
|
|
Modal sheet for managing limited access contact set. For bulk or non-immediate use cases.
|
|
|
|
```swift
|
|
@State private var isPresented = false
|
|
|
|
Button("Share More Contacts") {
|
|
isPresented.toggle()
|
|
}
|
|
.contactAccessPicker(isPresented: $isPresented) { identifiers in
|
|
// identifiers: [String] — newly permitted contacts only
|
|
let contacts = await fetchContacts(withIdentifiers: identifiers)
|
|
}
|
|
```
|
|
|
|
**Difference from CNContactPickerViewController**: `contactAccessPicker` changes persistent access. `CNContactPickerViewController` provides one-time snapshots.
|
|
|
|
---
|
|
|
|
# Part 10: ContactProvider Framework (iOS 18+)
|
|
|
|
Enables apps to expose contacts to the system Contacts ecosystem from third-party sources.
|
|
|
|
## Architecture
|
|
|
|
1. **Main app** controls the extension via `ContactProviderManager`
|
|
2. **Extension** enumerates contacts to the system
|
|
3. Communication via **App Group** shared container
|
|
|
|
## ContactProviderManager (Main App Only)
|
|
|
|
```swift
|
|
let manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts")
|
|
|
|
try await manager.enable() // Async — may prompt user
|
|
try await manager.disable() // Deactivate
|
|
try await manager.reset() // Clear all provider contacts
|
|
try await manager.invalidate() // Terminate extension
|
|
try await manager.signalEnumerator(for: .default) // Trigger enumeration
|
|
|
|
manager.isEnabled // Bool — activation state
|
|
```
|
|
|
|
**Cannot be used in app extensions** — main app only.
|
|
|
|
## ContactProviderExtension Protocol
|
|
|
|
```swift
|
|
@main
|
|
class Provider: ContactProviderExtension {
|
|
func configure(for domain: ContactProviderDomain) {
|
|
// Setup data access
|
|
}
|
|
|
|
func enumerator(for collection: ContactItem.Identifier)
|
|
-> ContactItemEnumerator {
|
|
return MyEnumerator()
|
|
}
|
|
|
|
func invalidate() async throws {
|
|
// Cleanup
|
|
}
|
|
}
|
|
```
|
|
|
|
**Info.plist**: Extension point `com.apple.contact.provider.extension`
|
|
|
|
## Enumeration
|
|
|
|
Two patterns:
|
|
1. **Content enumeration** — full initial sync via `ContactItemContentObserver`
|
|
2. **Change enumeration** — incremental updates via `ContactItemChangeObserver` and `ContactItemSyncAnchor`
|
|
|
|
```swift
|
|
class MyEnumerator: ContactItemEnumerator {
|
|
func enumerateContent(
|
|
in page: ContactItemPage,
|
|
for observer: ContactItemContentObserver
|
|
) {
|
|
let contact = CNMutableContact()
|
|
contact.givenName = "Jane"
|
|
contact.familyName = "Appleseed"
|
|
let item = ContactItem.contact(contact, ContactItem.Identifier("jane-001"))
|
|
observer.didEnumerate([item])
|
|
observer.didFinishEnumeratingContent(upTo: generationMarker)
|
|
}
|
|
|
|
func enumerateChanges(
|
|
startingAt anchor: ContactItemSyncAnchor,
|
|
for observer: ContactItemChangeObserver
|
|
) {
|
|
// Incremental updates since anchor
|
|
observer.didFinishEnumeratingChanges(upTo: newAnchor)
|
|
}
|
|
}
|
|
```
|
|
|
|
## ContactProvider Errors
|
|
|
|
| Code | Meaning |
|
|
|------|---------|
|
|
| `featureNotAvailable` | Framework not available |
|
|
| `deniedByUser` | User rejected |
|
|
| `extensionNotFound` | Extension not registered |
|
|
| `enumerationTimeout` | Extension too slow |
|
|
| `cannotEnumerate` | Enumeration failed |
|
|
| `pageExpired` | Content page expired |
|
|
| `changeAnchorExpired` | Sync anchor expired |
|
|
| `itemsLimitReached` | Too many contacts |
|
|
|
|
---
|
|
|
|
# Part 11: Change History (TN3149)
|
|
|
|
## CNChangeHistoryFetchRequest
|
|
|
|
```swift
|
|
let request = CNChangeHistoryFetchRequest()
|
|
request.startingToken = savedToken // nil = full fetch
|
|
request.includeGroupChanges = false // Default NO
|
|
request.mutableObjects = false // Default NO
|
|
request.shouldUnifyResults = true // Default YES
|
|
request.additionalContactKeyDescriptors = [
|
|
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
|
|
]
|
|
request.excludedTransactionAuthors = [Bundle.main.bundleIdentifier!]
|
|
```
|
|
|
|
## CNChangeHistoryEventVisitor Protocol
|
|
|
|
```swift
|
|
// Required
|
|
func visit(_ event: CNChangeHistoryDropEverythingEvent) // Full re-sync
|
|
func visit(_ event: CNChangeHistoryAddContactEvent) // New contact
|
|
func visit(_ event: CNChangeHistoryUpdateContactEvent) // Modified
|
|
func visit(_ event: CNChangeHistoryDeleteContactEvent) // Deleted
|
|
|
|
// Optional (when includeGroupChanges = true)
|
|
func visit(_ event: CNChangeHistoryAddGroupEvent)
|
|
func visit(_ event: CNChangeHistoryUpdateGroupEvent)
|
|
func visit(_ event: CNChangeHistoryDeleteGroupEvent)
|
|
func visit(_ event: CNChangeHistoryAddMemberToGroupEvent)
|
|
func visit(_ event: CNChangeHistoryRemoveMemberFromGroupEvent)
|
|
func visit(_ event: CNChangeHistoryAddSubgroupToGroupEvent)
|
|
func visit(_ event: CNChangeHistoryRemoveSubgroupFromGroupEvent)
|
|
```
|
|
|
|
**Must use visitor pattern** — do NOT use `isKindOfClass:` to determine event type.
|
|
|
|
**Gotcha**: `enumeratorForChangeHistoryFetchRequest:error:` is **Objective-C only** — unavailable in Swift.
|
|
|
|
**Token expiration**: Returns `DropEverything` + `Add` events for all contacts — same code handles full and incremental sync.
|
|
|
|
**Transaction authors**: Use reverse-domain notation (bundle identifier). Filters results but doesn't provide attribution.
|
|
|
|
---
|
|
|
|
# Part 12: Error Reference
|
|
|
|
## CNError Codes
|
|
|
|
| Category | Code | Meaning |
|
|
|----------|------|---------|
|
|
| Authorization | `authorizationDenied` | No permission |
|
|
| Authorization | `featureDisabledByUser` | Feature turned off |
|
|
| Data | `recordDoesNotExist` | Contact/group deleted |
|
|
| Data | `recordNotWritable` | Read-only contact |
|
|
| Data | `insertedRecordAlreadyExists` | Duplicate insert |
|
|
| Validation | `validationTypeMismatch` | Wrong value type |
|
|
| Validation | `validationMultipleErrors` | Multiple validation failures |
|
|
| History | `changeHistoryExpired` | Sync token expired |
|
|
| History | `changeHistoryInvalidAnchor` | Bad sync anchor |
|
|
| History | `changeHistoryInvalidFetchRequest` | Invalid request |
|
|
|
|
Error `userInfo` provides: `affectedRecords`, `affectedRecordIdentifiers`, `keyPaths`.
|
|
|
|
---
|
|
|
|
# Part 13: Platform Availability
|
|
|
|
| API | iOS | macOS | watchOS | visionOS |
|
|
|-----|-----|-------|---------|----------|
|
|
| CNContactStore | 9.0+ | 10.11+ | 2.0+ | 1.0+ |
|
|
| Limited access | 18.0+ | — | — | — |
|
|
| CNContactPickerViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
|
|
| CNContactViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
|
|
| ContactAccessButton | 18.0+ | — | — | — |
|
|
| contactAccessPicker | 18.0+ | — | — | — |
|
|
| ContactProvider | 18.0+ | — | — | — |
|
|
| CNChangeHistoryFetchRequest | 13.0+ | 10.15+ | — | 1.0+ |
|
|
| CNSaveRequest | 9.0+ | 10.11+ | — | 1.0+ |
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2024-10121
|
|
|
|
**Docs**: /contacts, /contacts/cncontactstore, /contacts/cnmutablecontact, /contactsui, /contactsui/cncontactpickerviewcontroller, /contactprovider, /technotes/tn3149
|
|
|
|
**Skills**: contacts, eventkit-ref, privacy-ux
|