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")
])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.
Important
The contact note field is restricted by Apple. Reading it requires the special
com.apple.developer.contacts.notes entitlement, which you must
request from and be approved by Apple.
Fetching a contact with the note key in keysToFetch while 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 needed
let contact = try manager.getContact(id: contactID)
// Opt in — only works if your app has the com.apple.developer.contacts.notes entitlement
let 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.
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" />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
break
case .limited:
// Limited access (iOS 18+)
break
case .denied:
// User denied access
break
case .restricted:
// Access restricted by policy
break
case .unknown:
// Not yet determined - request permission
let result = await ContactManager.requestContactsPermission()
if result == .authorized {
// Access granted
}
}let manager = ContactManager.shared
let result = try manager.getContacts()
for contact in result.contacts {
print("\(contact.displayName): \(contact.phoneNumbers.first?.value ?? "")")
}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 results
if result.hasNextPage {
// Fetch next page
let nextOptions = ContactFetchOptions(
nameFilter: "John",
pageSize: 20,
pageOffset: 20
)
let nextResult = try manager.getContacts(options: nextOptions)
}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)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)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 number
let byPhone = try manager.getContacts(matchingPhoneNumber: "+1 (555) 012-3456")
// By email address
let 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.
let hasAny = try manager.hasContacts()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 addresses
let people = try manager.getContacts(options: ContactFetchOptions(fields: .summary))
// A custom set: just names and images
let withPhotos = try manager.getContacts(options: ContactFetchOptions(fields: ContactFields.name.union(.image)))
// Everything, including image data and (with the entitlement) the note
let 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.
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)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)
}try manager.deleteContact(id: contactID)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 order
let 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 ID
try 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.
// List groups
let groups = try manager.getGroups()
for group in groups {
print("\(group.name) (\(group.id ?? ""))")
}
// Create a group
let groupID = try manager.createGroup(name: "Book Club")
// Add a contact to a group
try manager.addContactToGroup(contactID: contactID, groupID: groupID)
// List all contacts in a group
let members = try manager.getContacts(inGroup: groupID)
// Remove a contact from a group
try manager.removeContactFromGroup(contactID: contactID, groupID: groupID)
// Delete a group
try manager.deleteGroup(id: groupID)// List containers (accounts)
let containers = try manager.getContainers()
for container in containers {
print("\(container.name) - \(container.type)")
}
// Get default container
let defaultID = try manager.getDefaultContainerID()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.
Note
Change notifications are coalesced and delivered asynchronously by the platform — a single notification may cover several edits, and you should treat it as "something changed, refetch" rather than a precise diff.
SkipContacts provides SwiftUI view modifiers for presenting native contact interfaces.
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")
}
)
}
}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
)
}
}Present the native editor for creating or editing contacts:
// Create a new contact with defaults
struct 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 contact
struct 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)")
}
)
}
}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 |
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
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"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 yearif let image = contact.image {
if let thumbnail = image.thumbnailData {
// Use thumbnail data
}
if let fullImage = image.imageData {
// Use full-size image data
}
}Access the underlying CNContactStore for advanced operations not covered by the cross-platform API:
#if !SKIP
import Contacts
let store = ContactManager.shared.contactStore
// Use CNContactStore directly
let predicate = CNContact.predicateForContacts(matchingEmailAddress: "test@example.com")
let keys: [CNKeyDescriptor] = [CNContactGivenNameKey as CNKeyDescriptor]
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
#endifIn #if SKIP blocks, you can use Android's ContactsContract directly:
#if SKIP
let context = ProcessInfo.processInfo.androidContext
let resolver = context.getContentResolver()
let cursor = resolver.query(
android.provider.ContactsContract.Contacts.CONTENT_URI,
nil, nil, nil, nil
)
// Process cursor...
cursor?.close()
#endif| 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 |
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).
This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
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.
This software is licensed under the Mozilla Public License 2.0.