Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,31 @@ Or in your `.xcconfig`:
INFOPLIST_KEY_NSContactsUsageDescription = This app needs access to your contacts.
```

#### Reading contact notes (restricted)

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](https://developer.apple.com/contact/request/contact-note-field).
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, all fetch APIs in SkipContacts
**default to `includeNote: false`** so they work out of the box. Notes are only
read when you explicitly opt in:

```swift
// 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, includeNote: true)
```

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

Add the following permissions to your `AndroidManifest.xml` (or the test target's `Skip/AndroidManifest.xml`):
Expand Down Expand Up @@ -101,7 +126,7 @@ let options = ContactFetchOptions(
pageOffset: 0,
sortOrder: .givenName,
includeImages: true,
includeNote: true
includeNote: false // requires the com.apple.developer.contacts.notes entitlement on iOS
)
let result = try manager.getContacts(options: options)

Expand All @@ -127,6 +152,33 @@ if let contact = try manager.getContact(id: contactID, includeImages: true) {
}
```

### Fetch all contacts in a group

Pass a group identifier (see [Contact Groups](#contact-groups)) to retrieve every
contact that is a member of that group:

```swift
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 image/note inclusion:

```swift
let options = ContactFetchOptions(
groupID: groupID,
sortOrder: .familyName,
includeImages: true
)
let result = try manager.getContacts(options: options)
```

> Note: `groupID` takes precedence over `nameFilter`; to filter by both, fetch the
> group members and filter the results in Swift.

### Check if contacts exist

```swift
Expand Down Expand Up @@ -210,6 +262,9 @@ 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)

Expand Down
71 changes: 68 additions & 3 deletions Sources/SkipContacts/ContactManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,24 @@ public final class ContactManager {
}

/// Fetch a single contact by ID.
public func getContact(id: String, includeImages: Bool = false, includeNote: Bool = true) throws -> Contact? {
///
/// Note: requesting the note field (`includeNote: true`) requires the
/// `com.apple.developer.contacts.notes` entitlement on iOS.
public func getContact(id: String, includeImages: Bool = false, includeNote: Bool = false) throws -> Contact? {
let options = ContactFetchOptions(contactIDs: [id], includeImages: includeImages, includeNote: includeNote)
let result = try getContacts(options: options)
return result.contacts.first
}

/// Fetch all contacts that are members of the group with the given identifier.
///
/// Note: requesting the note field (`includeNote: true`) requires the
/// `com.apple.developer.contacts.notes` entitlement on iOS.
public func getContacts(inGroup groupID: String, includeImages: Bool = false, includeNote: Bool = false) throws -> [Contact] {
let options = ContactFetchOptions(groupID: groupID, includeImages: includeImages, includeNote: includeNote)
return try getContacts(options: options).contacts
}

/// Check whether any contacts exist in the database.
public func hasContacts() throws -> Bool {
let result = try getContacts(options: ContactFetchOptions(pageSize: 1))
Expand Down Expand Up @@ -248,6 +260,10 @@ extension ContactManager {
let predicate = CNContact.predicateForContacts(withIdentifiers: ids)
let cnContacts = try contactStore.unifiedContacts(matching: predicate, keysToFetch: keys)
contacts = cnContacts.map { contactFromCN($0, includeImages: options.includeImages, includeNote: options.includeNote) }
} else if let groupID = options.groupID, !groupID.isEmpty {
let predicate = CNContact.predicateForContactsInGroup(withIdentifier: groupID)
let cnContacts = try contactStore.unifiedContacts(matching: predicate, keysToFetch: keys)
contacts = cnContacts.map { contactFromCN($0, includeImages: options.includeImages, includeNote: options.includeNote) }
} else if let name = options.nameFilter, !name.isEmpty {
let predicate = CNContact.predicateForContacts(matchingName: name)
let cnContacts = try contactStore.unifiedContacts(matching: predicate, keysToFetch: keys)
Expand Down Expand Up @@ -474,7 +490,11 @@ extension ContactManager {
CNLabeledValue(label: rel.customLabel ?? rel.label.cnLabelValue, value: CNContactRelation(name: rel.name))
}

mutable.note = contact.note
// Writing the note field requires the `com.apple.developer.contacts.notes`
// entitlement on iOS, so only touch it when a note is actually provided.
if !contact.note.isEmpty {
mutable.note = contact.note
}

if let img = contact.image {
mutable.imageData = img.imageData
Expand All @@ -495,7 +515,10 @@ extension ContactManager {
guard let contactID = contact.id else {
throw ContactError.invalidData("Contact must have an id to update")
}
let keys = keysToFetch(options: ContactFetchOptions(includeImages: true, includeNote: true))
// Only fetch the restricted note key when the update actually carries a
// note to write; otherwise the fetch would require the
// `com.apple.developer.contacts.notes` entitlement for every update.
let keys = keysToFetch(options: ContactFetchOptions(includeImages: true, includeNote: !contact.note.isEmpty))
let cnContact = try contactStore.unifiedContact(withIdentifier: contactID, keysToFetch: keys)
let mutable = cnContact.mutableCopy() as! CNMutableContact
applyCNContactProperties(contact, to: mutable)
Expand Down Expand Up @@ -601,6 +624,16 @@ extension ContactManager {
let placeholders = ids.map { _ in "?" }.joined(separator: ",")
selection = "\(android.provider.ContactsContract.Contacts._ID) IN (\(placeholders))"
selectionArgs = ids
} else if let groupID = options.groupID, !groupID.isEmpty {
// Resolve the aggregate contact IDs that are members of the group,
// then constrain the Contacts query to those IDs.
let memberIDs = androidContactIDsInGroup(resolver: resolver, groupID: groupID)
if memberIDs.isEmpty {
return ContactFetchResult(contacts: [], hasNextPage: false)
}
let placeholders = memberIDs.map { _ in "?" }.joined(separator: ",")
selection = "\(android.provider.ContactsContract.Contacts._ID) IN (\(placeholders))"
selectionArgs = memberIDs
} else if let name = options.nameFilter, !name.isEmpty {
selection = "\(android.provider.ContactsContract.Contacts.DISPLAY_NAME_PRIMARY) LIKE ?"
selectionArgs = ["%\(name)%"]
Expand Down Expand Up @@ -669,6 +702,38 @@ extension ContactManager {
return ContactFetchResult(contacts: contacts, hasNextPage: false)
}

/// Returns the distinct aggregate contact IDs that belong to the group with the given identifier.
private func androidContactIDsInGroup(resolver: android.content.ContentResolver, groupID: String) -> [String] {
let dataUri = android.provider.ContactsContract.Data.CONTENT_URI
let selection = "\(android.provider.ContactsContract.Data.MIMETYPE) = ? AND \(android.provider.ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID) = ?"
let args = [android.provider.ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE, groupID]

let cursor = resolver.query(
dataUri,
[android.provider.ContactsContract.Data.CONTACT_ID].toList().toTypedArray(),
selection,
args.toList().toTypedArray(),
nil
)

var ids: [String] = []
var seen = Set<String>()
if let cursor = cursor {
let idIndex = cursor.getColumnIndex(android.provider.ContactsContract.Data.CONTACT_ID)
while cursor.moveToNext() {
// A single aggregate contact may have multiple raw contacts in the
// group, so de-duplicate the resulting contact IDs.
let contactID = cursor.getString(idIndex) ?? ""
if !contactID.isEmpty && !seen.contains(contactID) {
seen.insert(contactID)
ids.append(contactID)
}
}
cursor.close()
}
return ids
}

private func loadAndroidContactDetails(resolver: android.content.ContentResolver, contact: Contact, contactID: String, includeImages: Bool, includeNote: Bool) {
let dataUri = android.provider.ContactsContract.Data.CONTENT_URI
let dataSelection = "\(android.provider.ContactsContract.Data.CONTACT_ID) = ?"
Expand Down
13 changes: 11 additions & 2 deletions Sources/SkipContacts/ContactTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,8 @@ public final class ContactFetchOptions {
public var nameFilter: String?
/// Filter contacts by specific IDs.
public var contactIDs: [String]?
/// Filter contacts by membership in the group with this identifier.
public var groupID: String?
/// Maximum number of results.
public var pageSize: Int?
/// Offset for pagination.
Expand All @@ -605,20 +607,27 @@ public final class ContactFetchOptions {
public var sortOrder: ContactSortOrder
/// Whether to include image data in results.
public var includeImages: Bool
/// Whether to include note field.
/// Whether to include the note field. Defaults to `false`.
///
/// On iOS, reading the note field requires the special
/// `com.apple.developer.contacts.notes` entitlement, which must be requested
/// from and approved by Apple. Without it, fetches that request notes will
/// fail. See the README for details.
public var includeNote: Bool

public init(
nameFilter: String? = nil,
contactIDs: [String]? = nil,
groupID: String? = nil,
pageSize: Int? = nil,
pageOffset: Int? = nil,
sortOrder: ContactSortOrder = .none,
includeImages: Bool = false,
includeNote: Bool = true
includeNote: Bool = false
) {
self.nameFilter = nameFilter
self.contactIDs = contactIDs
self.groupID = groupID
self.pageSize = pageSize
self.pageOffset = pageOffset
self.sortOrder = sortOrder
Expand Down
59 changes: 57 additions & 2 deletions Tests/SkipContactsTests/SkipContactsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,12 @@ let logger: Logger = Logger(subsystem: "SkipContacts", category: "Tests")
let options = ContactFetchOptions()
#expect(options.nameFilter == nil)
#expect(options.contactIDs == nil)
#expect(options.groupID == nil)
#expect(options.pageSize == nil)
#expect(options.pageOffset == nil)
#expect(options.sortOrder == .none)
#expect(options.includeImages == false)
#expect(options.includeNote == true)
#expect(options.includeNote == false)
}

@Test func testFetchOptionsCustom() throws {
Expand All @@ -295,6 +296,13 @@ let logger: Logger = Logger(subsystem: "SkipContacts", category: "Tests")
#expect(options.includeNote == false)
}

@Test func testFetchOptionsGroupFilter() throws {
let options = ContactFetchOptions(groupID: "group-42")
#expect(options.groupID == "group-42")
#expect(options.nameFilter == nil)
#expect(options.contactIDs == nil)
}

// MARK: - Fetch Result

@Test func testFetchResult() throws {
Expand Down Expand Up @@ -732,6 +740,53 @@ private func withTestContact(_ contact: Contact, body: (String) throws -> Void)
try manager.deleteGroup(id: groupID)
}

@Test func testFetchContactsInGroup() throws {
guard isLiveDevice() else { return }

let manager = ContactManager.shared
let memberName = "SkipGrpMember\(Int.random(in: 10000..<99999))"
let member = Contact(givenName: memberName, familyName: "InGroup")
let nonMember = Contact(givenName: "SkipGrpOutsider\(Int.random(in: 10000..<99999))", familyName: "NotInGroup")
let memberID = try manager.createContact(member)
let nonMemberID = try manager.createContact(nonMember)
let groupName = "SkipFetchGrp\(Int.random(in: 10000..<99999))"
let groupID = try manager.createGroup(name: groupName)

func cleanup() {
try? manager.deleteContact(id: memberID)
try? manager.deleteContact(id: nonMemberID)
try? manager.deleteGroup(id: groupID)
}

do {
// An empty group should yield no members.
let empty = try manager.getContacts(inGroup: groupID)
#expect(empty.isEmpty)

try manager.addContactToGroup(contactID: memberID, groupID: groupID)

// The group should now contain exactly the member, not the outsider.
let members = try manager.getContacts(inGroup: groupID)
#expect(members.contains { $0.givenName == memberName })
#expect(!members.contains { $0.familyName == "NotInGroup" })

// The same query is also reachable via the options API.
let viaOptions = try manager.getContacts(options: ContactFetchOptions(groupID: groupID))
#expect(viaOptions.contacts.contains { $0.givenName == memberName })

try manager.removeContactFromGroup(contactID: memberID, groupID: groupID)

// After removal the group is empty again.
let afterRemoval = try manager.getContacts(inGroup: groupID)
#expect(!afterRemoval.contains { $0.givenName == memberName })
} catch {
cleanup()
throw error
}

cleanup()
}

// MARK: - Containers

@Test func testGetContainers() throws {
Expand Down Expand Up @@ -811,7 +866,7 @@ private func withTestContact(_ contact: Contact, body: (String) throws -> Void)
)
]
contact.urlAddresses = [
ContactURLAddress(label: .homepage, value: "https://skip.tools")
ContactURLAddress(label: .homepage, value: "https://skip.dev")
]
contact.note = "Integration test complex contact"

Expand Down
Loading