There seem to be an issue with CNContactStore
and enumeratorForChangeHistoryFetchRequest:error: is not available in Swift.
It is possible to wrap an instance of CNContactStore
in an Objective-C class :
ContactStoreWrapper.h
// ContactStoreWrapper.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class CNContactStore;
@class CNChangeHistoryFetchRequest;
@class CNFetchResult;
@interface ContactStoreWrapper : NSObject
- (instancetype)initWithStore:(CNContactStore *)store NS_DESIGNATED_INITIALIZER;
- (CNFetchResult *)changeHistoryFetchResult:(CNChangeHistoryFetchRequest *)request
error:(NSError *__autoreleasing _Nullable * _Nullable)error;
@end
NS_ASSUME_NONNULL_END
ContactStoreWrapper.m
#import "ContactStoreWrapper.h"
@import Contacts;
@interface ContactStoreWrapper ()
@property (nonatomic, strong) CNContactStore *store;
@end
@implementation ContactStoreWrapper
- (instancetype)init {
return [self initWithStore:[[CNContactStore alloc] init]];
}
- (instancetype)initWithStore:(CNContactStore *)store {
if (self = [super init]) {
_store = store;
}
return self;
}
- (CNFetchResult *)changeHistoryFetchResult:(CNChangeHistoryFetchRequest *)request
error:(NSError *__autoreleasing _Nullable * _Nullable)error {
CNFetchResult *fetchResult = [self.store enumeratorForChangeHistoryFetchRequest:request error:error];
return fetchResult;
}
@end
Synchronizing access to contacts
Next I created an actor in order to synchronize contacts updates :
@globalActor
actor ContactActor {
static let shared = ContactActor()
@Published private(set) var contacts: Set<CNContact> = []
func removeAll() {
contacts.removeAll()
}
func insert(_ contact: CNContact) {
let (inserted, _) = contacts.insert(contact)
if !inserted {
print("insertion failure")
}
}
func update(with contact: CNContact) {
delete(contactIdentifier: contact.identifier)
insert(contact)
}
func delete(contactIdentifier: String) {
let contactToRemove = contacts.first { contact in
contact.identifier == contactIdentifier
}
guard let contactToRemove else { return print("deletion failure") }
contacts.remove(contactToRemove)
}
}
Visitor
And a visitor, conforming to CNChangeHistoryEventVisitor
, to responds to history events and updates the actor.
class Visitor: NSObject, CNChangeHistoryEventVisitor {
let contactActor = ContactActor.shared
func visit(_ event: CNChangeHistoryDropEverythingEvent) {
Task {
await contactActor.removeAll()
}
}
func visit(_ event: CNChangeHistoryAddContactEvent) {
Task { @ContactActor in
await contactActor.insert(event.contact)
}
}
func visit(_ event: CNChangeHistoryUpdateContactEvent) {
Task {
await contactActor.update(with: event.contact)
}
}
func visit(_ event: CNChangeHistoryDeleteContactEvent) {
Task {
await contactActor.delete(contactIdentifier: event.contactIdentifier)
}
}
}
Fetching Contact History
Then I created a small helper class in Swift to fetch the history changes, and responds to external changes of the contact store using CNContactStoreDidChange
:
class ContactHistoryFetcher: ObservableObject {
@MainActor @Published private(set) var contacts: [Row] = []
private let savedTokenUserDefaultsKey = "CNContactChangeHistoryToken"
private let store: CNContactStore
private let visitor = Visitor()
private let formatter = {
let formatter = CNContactFormatter()
formatter.style = .fullName
return formatter
}()
private var savedToken: Data? {
get {
UserDefaults.standard.data(forKey: savedTokenUserDefaultsKey)
}
set {
UserDefaults.standard.set(newValue, forKey: savedTokenUserDefaultsKey)
}
}
init(store: CNContactStore = .init()) {
self.store = store
}
private var cancellables: Set<AnyCancellable> = []
@ContactActor func bind() async {
let contacts = await ContactActor.shared.$contacts.share()
// Observing `CNContactStoreDidChange` notification to responds to change while the app is running
// for example if a contact have been changed on another device
NotificationCenter.default
.publisher(for: .CNContactStoreDidChange)
.sink { [weak self] notification in
Task {
await self?.fetchChanges()
}
}
.store(in: &cancellables)
let formatter = formatter
contacts
.map { contacts in
contacts
.compactMap { contact in
formatter.string(for: contact)
}
.sorted()
.map(Row.init(text:))
}
.receive(on: DispatchQueue.main)
.assign(to: &$contacts)
}
@MainActor func reset() {
UserDefaults.standard.set(nil, forKey: savedTokenUserDefaultsKey)
Task {
await ContactActor.shared.removeAll()
}
}
@MainActor func fetchChanges() async {
let fetchHistoryRequest = CNChangeHistoryFetchRequest()
// At first launch, the startingToken will be nil and all contacts will be retrieved as additions
fetchHistoryRequest.startingToken = savedToken
// We only need the given name for this simple use case
fetchHistoryRequest.additionalContactKeyDescriptors = [CNContactFormatter.descriptorForRequiredKeys(for: .fullName)]
let wrapper = ContactStoreWrapper(store: store)
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async { [self] in
let result = wrapper.changeHistoryFetchResult(fetchHistoryRequest, error: nil)
// Saving the result's token as stated in CNContactStore documentation, ie:
// https://developer.apple.com/documentation/contacts/cncontactstore/3113739-currenthistorytoken
// When fetching contacts or change history events, use the token on CNFetchResult instead.
savedToken = result.currentHistoryToken
guard let enumerator = result.value as? NSEnumerator else { return }
enumerator
.compactMap {
$0 as? CNChangeHistoryEvent
}
.forEach { event in
// The appropriate `visit(_:)` method will be called right away
event.accept(visitor)
}
continuation.resume()
}
}
}
}
UI
Now we can display the result in a simple view
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Row: Identifiable {
let id = UUID()
let text: String
}
struct ContentView: View {
@StateObject private var fetcher = ContactHistoryFetcher()
var body: some View {
List {
Section(header: Text("contacts")) {
ForEach(fetcher.contacts) { row in
Text(row.text)
}
}
Button("Reset") {
fetcher.reset()
}
Button("Fetch") {
Task {
await fetcher.fetchChanges()
}
}
}
.padding()
.task {
await fetcher.bind()
await fetcher.fetchChanges()
}
}
}
currentHistoryToken
of the last fetch – Vegetable