Contacts
A cross-platform contacts framework for Skip apps, providing a unified API for querying, creating, updating, and deleting contacts on both iOS and Android.
On iOS, SkipContacts wraps Apple’s Contacts ↗ and ContactsUI ↗ frameworks. On Android, it uses the ContactsContract ↗ content provider.
To use SkipContacts in your project, add the dependency to your Package.swift:
dependencies: [ .package(url: "https://source.skip.tools/skip-contacts.git", "0.0.0"..<"2.0.0")]And add SkipContacts as a dependency of your target:
.target(name: "MyApp", dependencies: [ .product(name: "SkipContacts", package: "skip-contacts")])Prerequisites
Section titled “Prerequisites”Add the following usage description to your app’s Info.plist or .xcconfig:
<key>NSContactsUsageDescription</key><string>This app needs access to your contacts.</string>Or in your .xcconfig:
INFOPLIST_KEY_NSContactsUsageDescription = This app needs access to your contacts.Reading contact notes (restricted)
Section titled “Reading contact notes (restricted)”
com.apple.developer.contacts.notesentitlement, which you must request from and be approved by Apple ↗. Fetching a contact with the note key inkeysToFetchwhile the app lacks this entitlement will fail.
Because most apps do not have this entitlement, the note field is excluded
from the default field set (.default) so fetches work out of the box. Notes are
only read when you explicitly request the .note field (see
Selecting fields to fetch):
// Default — does not touch the restricted note field, no entitlement neededlet contact = try manager.getContact(id: contactID)
// Opt in — only works if your app has the com.apple.developer.contacts.notes entitlementlet withNote = try manager.getContact(id: contactID, fields: .all)Writing the note field (setting Contact.note to a non-empty value before
createContact/updateContact) likewise requires the entitlement. If your app
does not have it, leave Contact.note empty. This restriction is iOS-only; on
Android the note is read and written normally.
Android
Section titled “Android”Add the following permissions to your AndroidManifest.xml (or the test target’s Skip/AndroidManifest.xml):
<uses-permission android:name="android.permission.READ_CONTACTS" /><uses-permission android:name="android.permission.WRITE_CONTACTS" />Permissions
Section titled “Permissions”Always check and request contacts permission before performing operations:
import SkipContacts
// Check current permission status (synchronous, no prompt)let status = ContactManager.queryContactsPermission()
switch status {case .authorized: // Full access granted breakcase .limited: // Limited access (iOS 18+) breakcase .denied: // User denied access breakcase .restricted: // Access restricted by policy breakcase .unknown: // Not yet determined - request permission let result = await ContactManager.requestContactsPermission() if result == .authorized { // Access granted }}Fetching Contacts
Section titled “Fetching Contacts”Fetch all contacts
Section titled “Fetch all contacts”let manager = ContactManager.sharedlet result = try manager.getContacts()for contact in result.contacts { print("\(contact.displayName): \(contact.phoneNumbers.first?.value ?? "")")}Fetch with options
Section titled “Fetch with options”let options = ContactFetchOptions( nameFilter: "John", pageSize: 20, pageOffset: 0, sortOrder: .givenName, fields: .default // every field except image and the restricted note; see "Selecting fields to fetch")let result = try manager.getContacts(options: options)
// Check if there are more resultsif result.hasNextPage { // Fetch next page let nextOptions = ContactFetchOptions( nameFilter: "John", pageSize: 20, pageOffset: 20 ) let nextResult = try manager.getContacts(options: nextOptions)}Fetch a single contact by ID
Section titled “Fetch a single contact by ID”if let contact = try manager.getContact(id: contactID) { print(contact.displayName) print(contact.givenName) print(contact.familyName)}
// Fetch only the fields you need (see "Selecting fields to fetch")let light = try manager.getContact(id: contactID, fields: .summary)Fetch all contacts in a group
Section titled “Fetch all contacts in a group”Pass a group identifier (see Contact Groups) to retrieve every contact that is a member of that group:
let contacts = try manager.getContacts(inGroup: groupID)for contact in contacts { print(contact.displayName)}The same filter is available on ContactFetchOptions via groupID, so it can be
combined with sorting, pagination, and field selection:
let options = ContactFetchOptions( groupID: groupID, sortOrder: .familyName, fields: .summary)let result = try manager.getContacts(options: options)Find contacts by phone number or email
Section titled “Find contacts by phone number or email”Look up contacts by a phone number or email address. Matching is performed by the platform using its own normalization rules, so formatting differences (spaces, dashes, parentheses, and — on Android — country-code variations) are generally ignored:
// By phone numberlet byPhone = try manager.getContacts(matchingPhoneNumber: "+1 (555) 012-3456")
// By email addresslet byEmail = try manager.getContacts(matchingEmail: "jane@example.com")The same filters are available on ContactFetchOptions via phoneNumberFilter and
emailFilter, so they compose with sorting, pagination, and field selection:
let options = ContactFetchOptions(phoneNumberFilter: "+15550123456", fields: .summary)let result = try manager.getContacts(options: options)Note: when more than one filter is set, only the most specific one is applied. The precedence is
contactIDs→groupID→phoneNumberFilter→emailFilter→nameFilter. To combine filters, fetch with one and filter the results in Swift.
Check if contacts exist
Section titled “Check if contacts exist”let hasAny = try manager.hasContacts()Selecting fields to fetch
Section titled “Selecting fields to fetch”By default, fetches populate every field except image data and the
entitlement-restricted note. When you only need some of the data — for example,
name and phone number for a list row — request a narrower set of fields with the
ContactFields option set. The platform then loads only the requested keys, which
can substantially improve performance and reduce memory use on large address books.
Conversely, pass .all to also load images and (with the entitlement) notes.
// Lightweight fetch for a list: name, phone numbers, and email addresseslet people = try manager.getContacts(options: ContactFetchOptions(fields: .summary))
// A custom set: just names and imageslet withPhotos = try manager.getContacts(options: ContactFetchOptions(fields: ContactFields.name.union(.image)))
// Everything, including image data and (with the entitlement) the notelet full = try manager.getContacts(options: ContactFetchOptions(fields: .all))Available fields and presets:
| Field | Contents |
|---|---|
.name | Prefix, given, middle, family, suffix, nickname, phonetic names |
.phoneNumbers | Phone numbers |
.emailAddresses | Email addresses |
.postalAddresses | Postal addresses |
.organization | Organization, department, job title |
.urlAddresses | URL addresses |
.instantMessageAddresses | Instant message addresses |
.socialProfiles | Social profiles (read support is iOS-only) |
.dates | Birthday and other dates |
.relationships | Relationships |
.image | Thumbnail and full-size image data |
.note | Note field — requires the iOS notes entitlement (see above) |
.summary | .name, .phoneNumbers, .emailAddresses |
.default | Every field except .image and .note (the default) |
.all | Every field, including .image and .note |
Note: Unrequested fields are left at their empty defaults on the returned
Contact(e.g.postalAddressesis[],organizationNameis""). On iOS, requesting only the fields you need also avoids fetching keys you may not be entitled to.
Creating Contacts
Section titled “Creating Contacts”let contact = Contact( contactType: .person, givenName: "Jane", familyName: "Doe", organizationName: "Acme Corp", jobTitle: "Engineer")
contact.phoneNumbers = [ ContactPhoneNumber(label: .mobile, value: "+1-555-0123"), ContactPhoneNumber(label: .work, value: "+1-555-0456")]
contact.emailAddresses = [ ContactEmailAddress(label: .work, value: "jane@acme.com"), ContactEmailAddress(label: .home, value: "jane@example.com")]
contact.postalAddresses = [ ContactPostalAddress( label: .home, street: "123 Main St", city: "Springfield", state: "IL", postalCode: "62701", country: "USA" )]
contact.birthday = ContactDate(label: .birthday, day: 15, month: 6, year: 1990)
contact.relationships = [ ContactRelationship(label: .spouse, name: "John Doe")]
contact.note = "Met at conference"
let newID = try manager.createContact(contact)Updating Contacts
Section titled “Updating Contacts”Always fetch the full contact first, mutate it, then write it back. The update is performed in place (the identifier and unmanaged data like group membership are preserved), and it replaces the contact’s managed fields with what the model carries — see Updating contacts for the full semantics.
// Fetch the contact first (default `.default` fetches every field except image and the note)if let contact = try manager.getContact(id: contactID) { contact.jobTitle = "Senior Engineer" contact.phoneNumbers.append( ContactPhoneNumber(label: .home, value: "+1-555-9999") ) try manager.updateContact(contact)}Deleting Contacts
Section titled “Deleting Contacts”try manager.deleteContact(id: contactID)Batch Operations
Section titled “Batch Operations”Create, update, or delete many contacts at once. This is far more efficient than
issuing one call per contact: on iOS the whole batch is committed as a single
CNSaveRequest, and on Android as a single ContentResolver.applyBatch
transaction.
// Create many contacts; returns the new identifiers in input orderlet newIDs = try manager.createContacts([ Contact(givenName: "Ada", familyName: "Lovelace"), Contact(givenName: "Alan", familyName: "Turing"), Contact(givenName: "Grace", familyName: "Hopper")])
// Update many contacts (each must have a valid `id`)let edits = newIDs.map { id in let c = Contact(id: id, givenName: "Updated", familyName: "Name") return c}try manager.updateContacts(edits)
// Delete many contacts by IDtry manager.deleteContacts(ids: newIDs)Empty arrays are a no-op (and don’t require contacts permission). The return value
of createContacts is @discardableResult, so it can be ignored when the new
identifiers aren’t needed.
Note: All three batch operations are committed as a single transaction on both platforms (one
CNSaveRequeston iOS, oneContentResolver.applyBatchon Android), so a failure rolls the whole batch back. Updates are applied in place and preserve each contact’s identifier. See Updating contacts for the field-replacement semantics that apply to both single and batch updates.
Contact Groups
Section titled “Contact Groups”// List groupslet groups = try manager.getGroups()for group in groups { print("\(group.name) (\(group.id ?? ""))")}
// Create a grouplet groupID = try manager.createGroup(name: "Book Club")
// Add a contact to a grouptry manager.addContactToGroup(contactID: contactID, groupID: groupID)
// List all contacts in a grouplet members = try manager.getContacts(inGroup: groupID)
// Remove a contact from a grouptry manager.removeContactFromGroup(contactID: contactID, groupID: groupID)
// Delete a grouptry manager.deleteGroup(id: groupID)Containers / Accounts
Section titled “Containers / Accounts”// List containers (accounts)let containers = try manager.getContainers()for container in containers { print("\(container.name) - \(container.type)")}
// Get default containerlet defaultID = try manager.getDefaultContainerID()Observing Changes
Section titled “Observing Changes”Subscribe to changes in the contacts database — including edits made by other apps
(the system Contacts app, the picker/editor UI, or a sync) — so your UI can refresh.
On iOS this wraps the CNContactStoreDidChange notification; on Android it registers
a ContentObserver on the contacts content URI. The handler is invoked on the main
thread; discard any cached Contact objects and refetch inside it.
// Retain the observer for as long as you want notifications.let observer = manager.observeChanges { reloadContacts() // e.g. refetch and update your UI}
// Stop observing (also called automatically if the observer is released).observer.cancel()The returned ContactObserver keeps the subscription alive, so store it (e.g. in a
property or view model). Releasing it or calling cancel() unregisters the
subscription; cancel() is idempotent and safe to call more than once.
a single notification may cover several edits, and you should treat it as “something changed, refetch” rather than a precise diff.
Contact UI
Section titled “Contact UI”SkipContacts provides SwiftUI view modifiers for presenting native contact interfaces.
Contact Picker
Section titled “Contact Picker”Present the system contact picker to let the user select a contact:
struct MyView: View { @State var showPicker = false @State var selectedContactID: String?
var body: some View { Button("Pick Contact") { showPicker = true } .withContactPicker( isPresented: $showPicker, onSelectContact: { contactID in selectedContactID = contactID // Fetch full contact details if let contact = try? ContactManager.shared.getContact(id: contactID) { print("Selected: \(contact.displayName)") } }, onCancel: { print("Picker cancelled") } ) }}Contact Viewer
Section titled “Contact Viewer”Display a contact’s details using the native viewer:
struct ContactDetailView: View { @State var showViewer = false let contactID: String
var body: some View { Button("View Contact") { showViewer = true } .withContactViewer( isPresented: $showViewer, contactID: contactID ) }}Contact Editor
Section titled “Contact Editor”Present the native editor for creating or editing contacts:
// Create a new contact with defaultsstruct CreateContactView: View { @State var showEditor = false
var body: some View { Button("New Contact") { showEditor = true } .withContactEditor( isPresented: $showEditor, options: ContactEditorOptions( defaultGivenName: "Jane", defaultFamilyName: "Doe", defaultPhoneNumber: "+1-555-0123", defaultEmailAddress: "jane@example.com" ), onComplete: { result in switch result { case .saved: print("Contact saved") case .deleted: print("Contact deleted") case .canceled: print("Cancelled") case .unknown: print("Unknown result") } } ) }}
// Edit an existing contactstruct EditContactView: View { @State var showEditor = false let contact: Contact
var body: some View { Button("Edit Contact") { showEditor = true } .withContactEditor( isPresented: $showEditor, options: ContactEditorOptions(contact: contact), onComplete: { result in print("Editor result: \(result)") } ) }}Contact Data Types
Section titled “Contact Data Types”Contact
Section titled “Contact”The Contact class contains all fields for a contact record:
| Property | Type | Description |
|---|---|---|
id | String? | Unique identifier (nil for new contacts) |
contactType | ContactType | .person or .organization |
namePrefix | String | e.g., “Dr.”, “Mr.” |
givenName | String | First name |
middleName | String | Middle name |
familyName | String | Last name |
nameSuffix | String | e.g., “Jr.”, “PhD” |
nickname | String | Nickname |
phoneticGivenName | String | Phonetic first name |
phoneticMiddleName | String | Phonetic middle name |
phoneticFamilyName | String | Phonetic last name |
previousFamilyName | String | Maiden name |
organizationName | String | Company name |
departmentName | String | Department |
jobTitle | String | Job title |
phoneNumbers | [ContactPhoneNumber] | Phone numbers |
emailAddresses | [ContactEmailAddress] | Email addresses |
postalAddresses | [ContactPostalAddress] | Postal addresses |
urlAddresses | [ContactURLAddress] | URL addresses |
instantMessageAddresses | [ContactInstantMessageAddress] | IM addresses |
socialProfiles | [ContactSocialProfile] | Social profiles (iOS only) |
birthday | ContactDate? | Birthday |
dates | [ContactDate] | Other dates (anniversary, etc.) |
relationships | [ContactRelationship] | Relationships |
note | String | Notes |
image | ContactImage? | Contact photo |
displayName | String | Computed display name |
Labels
Section titled “Labels”All labeled values (phone, email, address, etc.) support standard labels and custom labels:
Phone labels: main, home, work, mobile, iPhone, homeFax, workFax, pager, other
Email labels: home, work, iCloud, other
Address labels: home, work, other
Date labels: birthday, anniversary, other
Relationship labels: spouse, child, mother, father, parent, sibling, friend, manager, assistant, partner, other
URL labels: home, work, homepage, other
ContactPostalAddress
Section titled “ContactPostalAddress”let address = ContactPostalAddress( label: .home, street: "123 Main St", city: "Springfield", state: "IL", postalCode: "62701", country: "USA", isoCountryCode: "US")print(address.formattedAddress) // "123 Main St, Springfield, IL, 62701, USA"ContactDate
Section titled “ContactDate”Dates support year-less values for recurring events like birthdays:
let birthday = ContactDate(label: .birthday, day: 15, month: 6, year: 1990)let anniversary = ContactDate(label: .anniversary, day: 20, month: 9) // no yearContactImage
Section titled “ContactImage”if let image = contact.image { if let thumbnail = image.thumbnailData { // Use thumbnail data } if let fullImage = image.imageData { // Use full-size image data }}Platform-Specific Access
Section titled “Platform-Specific Access”Access the underlying CNContactStore for advanced operations not covered by the cross-platform API:
#if !SKIPimport Contacts
let store = ContactManager.shared.contactStore
// Use CNContactStore directlylet predicate = CNContact.predicateForContacts(matchingEmailAddress: "test@example.com")let keys: [CNKeyDescriptor] = [CNContactGivenNameKey as CNKeyDescriptor]let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)#endifAndroid
Section titled “Android”In #if SKIP blocks, you can use Android’s ContactsContract directly:
#if SKIPlet context = ProcessInfo.processInfo.androidContextlet resolver = context.getContentResolver()
let cursor = resolver.query( android.provider.ContactsContract.Contacts.CONTENT_URI, nil, nil, nil, nil)// Process cursor...cursor?.close()#endifPlatform Differences
Section titled “Platform Differences”| Feature | iOS | Android |
|---|---|---|
| Social profiles | Read + write | Not available (silently ignored) |
| Instant messages | Read + write | Read + write |
| Image data | Read + write (thumbnail + full-size) | Read + write (provider may downscale) |
| Notes | Read + write (needs entitlement) | Read + write |
| Custom labels | Read + write | Read + write |
| Contact type (person/org) | Reported by the system | Inferred (company set, no personal name) |
| Contact groups | Full support | Full support |
| Containers/Accounts | Full support (iCloud, Exchange, CardDAV) | Approximate (via RawContacts accounts) |
| Contact picker | CNContactPickerViewController | ACTION_PICK intent |
| Contact viewer | CNContactViewController | ACTION_VIEW intent |
| Contact editor | CNContactViewController | ACTION_INSERT/EDIT intent |
| Multiple selection | Supported | Not supported (single pick) |
| Previous family name | Supported | Not available (no column) |
| Postal ISO country code | Supported | Not available (no column) |
| Phonetic names | Supported | Supported |
Updating contacts
Section titled “Updating contacts”updateContact/updateContacts update in place on both platforms: the contact’s
identifier is preserved, and rows the library does not manage (notably group
membership and account binding) are left intact.
Update replaces the contact’s managed fields with what the supplied Contact
carries, so fetch before updating, mutate, then write back. A fetch with the
default field set (.default) loads every field except image and note, so the
round-trip fetch → mutate → update is safe for those. Updating a contact obtained
from a narrowed fetch (e.g. .summary) will clear the other fields that were not
fetched. The note and image are special-cased: they are only replaced when the
supplied model actually carries a value, so they are never silently cleared by a
fetch that omitted them (including the default fetch, which omits both).
Building
Section titled “Building”This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
Testing
Section titled “Testing”The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.
Releases
Section titled “Releases”The 4 most recent releases of skiptools/skip-contacts ↗:
- Release 0.2.1 ↗ — 2026-06-27
- Release 0.2.0 ↗ — 2026-06-25
- Release 0.1.0 ↗ — 2026-04-10
- Release 0.0.1 ↗ — 2026-04-01
Full history: github.com/skiptools/skip-contacts/releases ↗ — .atom feed ↗