How would I check if a CNContact has changed since the last time my iOS app saved it in the contact store?
Asked Answered
J

1

6

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?

Jarad answered 27/9, 2022 at 0:43 Comment(5)
did you find a way of detecting when contacts have changed?Bowery
@Gruntcakes No. I sure didn't.Jarad
@Gruntcakes I just made a bounty for this question. Hopefully someone knows a way.Jarad
Hope so, but it doesn't seem like there is.Bowery
"I don't want to save the date of modification in UserDefaults or CloudKit or Core Data or any other way of persisting data." Sadly I don't see how you would achieve that without storing the currentHistoryToken of the last fetchVegetable
V
2

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

Vegetable answered 27/3, 2023 at 22:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.