5

I would like to store data in the contact store that is part of CNContact. Is there a property of NSObject or CNContact that I can store the contents of a Data structure or NSDate object? I would like to be able to keep up with when a CNContact was last modified.

I haven't found anything way that Apple has given us to specifically do this. I don't want to save the date of modification in UserDefaults or CloudKit or Core Data or any other way of persisting data. I don't want to use the Note property of CNContact, since it would be able to be changed by the user. The dates instance property of CNContact is get only, and I haven't found any way of using that property to do what I want to do.

An alternative would be to compare hash values or to us the isEqual method of CNContact or use the === or == operators. Would that work?

AnderCover
  • 2,488
  • 3
  • 23
  • 42
daniel
  • 1,446
  • 3
  • 29
  • 65

1 Answers1

0

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()
        }
    }
}

Result

AnderCover
  • 2,488
  • 3
  • 23
  • 42