Wesley de Groot's Blog
Contact Provider Extension

Back

The ContactProvider framework lets your app create (business) contacts on the user's device.
It is the prefered way to add contacts to the user's address book without interferring with their (personal) address book.

In this post we will create a simple app that uses the ContactProvider framework to add a business contact to the user's address book.

How are we going to setup our application?

We want these features:

  • Adding a contact.
  • Reset the contacts database.
  • Sync to the contact provider extension (yes for this demo we'll do it manually).

We need a way to easily add and fetch contacts from our app's "database".
We will create an observable object that will hold the contacts.

Required actions for the app and extension

In our app we need to do the following actions:

  • We need to tell the system that we are able to provide contacts.

    let manager = try ContactProviderManager()
    try await manager.enable()
  • We need to synchronize the contacts with the system.

    let manager = try ContactProviderManager()
    try await manager.signalEnumerator()

In our extension we need to conform to the ContactItemEnumerator protocol.

Building the app

Add the Contact Provider Extension in your app.

To enable ContactProvider in your app, you need to add the Contact Provider Extension to your app.

Setup an App Group

To enable communication between your app and the Contact Provider Extension, you need to setup an App Group.
For this example we will use the group.nl.wesleydegroot.contactproviderdemo App Group.

Building the main application

import ContactProvider

struct ContentView: View {
    /// The database that holds the contacts.
    @ObservedObject
    var database = ContactItemDatabase()

    @State var firstName: String = ""
    @State var lastName: String = ""
    @State var email: String = ""
    @State var phoneNumber: String = ""

    /// The contact provider manager.
    let manager = try! ContactProviderManager()

    var body: some View {
        VStack {
            Button("Send to contact provider") {
                Task {
                    // Synchronise contacts
                    await synchroniseContacts() 
                }
            }
            .buttonStyle(.borderedProminent)
            Button("Reset Database") {
                Task {
                    // Reset the database
                    database.reset() 

                    // Reset all previously known contacts.
                    try? await manager.reset() 
                }
            }
            .buttonStyle(.borderedProminent)

            List {
                ForEach(database.contactItemsJSON, id: \.firstName) { item in
                    Text("\(item.firstName) \(item.lastName)")
                }
            }

            GroupBox {
                TextField("First name", text: $firstName)
                TextField("Last name", text: $lastName)
                TextField("Email", text: $email)
                TextField("Phone number", text: $phoneNumber)

                Button("Add") {
                    saveToDatabase()
                }
            }
            .task {
                randomContact()
            }
            .background(.secondary)
        }
        .padding()
        .task {
            do {
                // May prompt the person to enable the default domain.
                try await manager.enable()
            } catch {
                // Handle the error.
            }
        }
    }

    func saveToDatabase() {
        database.save(
            .init(
                firstName: firstName,
                lastName: lastName,
                phoneNumber: phoneNumber == "" ? nil : phoneNumber,
                emailAddress: email == "" ? nil : email
            )
        )

        Task {
            randomContact()
        }
    }

    func randomContact() {
        firstName = ["John", "Jane", "Wesley", "Alice", "Bob"].randomElement()!
            lastName = ["Doe", "Smith", "de Groot", "Johnson", "Williams"].randomElement()!
            email = "\(firstName.lowercased()).\(lastName.lowercased())@example.com"
            phoneNumber = "+31612345678"
    }

    func synchroniseContacts() async {
        do {
            try await manager.signalEnumerator()
        } catch {
            // Handle the error.
            print(error)
        }
    }
}

Building the "middleware" ContactItemDatabase

import Foundation
import Contacts
import ContactProvider

class ContactItemDatabase: ObservableObject {
    // The items that will be synchronised to the Contact Provider extension.
    @Published public var contactItems: [ContactItem] = []

    // The items that will be saved to the database.
    @Published public var contactItemsJSON: [ContactItemJSON] = []

    // The url of our shared database. (App Group)
    private let fileURL = FileManager.default.containerURL(
        // App Group identifier.
        forSecurityApplicationGroupIdentifier: "group.nl.wesleydegroot.contactproviderdemo"
    )?.appendingPathComponent("ContactItems").appendingPathExtension("json")

    // The JSON struct that will be saved to the database.
    struct ContactItemJSON: Codable, Hashable {
        /// The identifier of the contact.
        var identifier: String = UUID().uuidString

        /// The first name of the contact. (givenName)
        var firstName: String

        /// The last name of the contact.
        var lastName: String

        /// The phone number of the contact.
        var phoneNumber: String?

        /// The email address of the contact.
        var emailAddress: String?
    }

    init() {
        guard let fileURL else {
            print("Could not find file URL; is the App Group identifier correct?")
            return
        }

        // Load the contents of our json (database) file.
        if let data = try? Data(contentsOf: fileURL) {
            let decoder = JSONDecoder()
            // Decode the JSON data from our ContactItemJSON struct.
            if let decoded = try? decoder.decode([ContactItemJSON].self, from: data) {
                // Save the decoded data to our contactItemsJSON array.
                // And remove any duplicates Array(Set(...)).
                contactItemsJSON = Array(Set(decoded)) 

                // Walk trough our contacts
                for item in contactItemsJSON {
                    // Create a new contact.
                    let contact: CNMutableContact = .init()

                    // Set the first name of the contact.
                    contact.givenName = item.firstName

                    // Set the last name of the contact.
                    contact.familyName = item.lastName

                    // If the contact has a phone number, add it to the contact.
                    if let phoneNumber = item.phoneNumber {
                        contact.phoneNumbers = [
                            .init(
                                label: "Mobile",
                                value: CNPhoneNumber(stringValue: phoneNumber)
                            )
                        ]
                    }

                    // If the contact has an email address, add it to the contact.
                    if let emailAddress = item.emailAddress {
                        contact.emailAddresses = [
                            .init(
                                label: "Email",
                                value: emailAddress as NSString
                            )
                        ]
                    }

                    // Create a new contact item identifier, from the contact identifier.
                    let identifier: ContactItem.Identifier = .init(item.identifier)

                    // Add the contact to the contactItems array.
                    contactItems.append(
                        .contact(contact, identifier)
                    )
                }
            }
        }
    }

    /// Reset the database.
    func reset() {
        // Remove all `ContactItem` items.
        contactItems.removeAll()

        // Remove all `ContactItemJSON` items.
        contactItemsJSON.removeAll()

        // Save the empty array to the database.
        try? JSONEncoder().encode(contactItemsJSON).write(to: fileURL!)
    }

    func save(_ contactItem: ContactItemJSON) {
        // Append the new contact to the contactItemsJSON array.
        contactItemsJSON.append(contactItem)

        // Save the new contact to the database.
        try? JSONEncoder().encode(contactItemsJSON).write(to: fileURL!)
    }
}

Building the ContactProvider Extension

import Foundation
import ContactProvider
import Contacts
import OSLog

@main
class Provider: ContactProviderExtension {
    private let rootContainerEnumerator: ProviderRootContainerEnumerator

    required init() {
        rootContainerEnumerator = ProviderRootContainerEnumerator()
    }

    func configure(for domain: ContactProviderDomain) {
        rootContainerEnumerator.configure(for: domain)
    }

    func enumerator(for collection: ContactItem.Identifier) -> ContactItemEnumerator {
        return rootContainerEnumerator
    }

    func invalidate() async throws {
        // TODO: Stop any enumeration and cleanup as the extension will be terminated.
    }
}

class ProviderRootContainerEnumerator: ContactItemEnumerator {
    /// The database that holds the contacts.
    private let database = ContactItemDatabase()

    func configure(for domain: ContactProviderDomain) {
        // TODO: If needed, configure your enumerator for the domain.
    }

    func enumerateContent(in page: ContactItemPage, for observer: ContactItemContentObserver) async {
        // Send the current contacts to the observer.
        observer.didEnumerate(self.database.contactItems)

        // Signal that we are done with the enumeration.
        observer.didFinishEnumeratingContent(upTo: "<currentDatabaseGenerationMarker>".data(using: .utf8)!)
    }


    func enumerateChanges(startingAt syncAnchor: ContactItemSyncAnchor, for observer: ContactItemChangeObserver) async {
        // Send the changes to the observer.
        observer.didUpdate(self.database.contactItems)

        // In this demo we dont support "remove" altough it can be implemented by giving the identifiers for the deleted items
        // observer.didDelete(self.database.deletedItemIdentifiers)

        // Signal that we are done with the enumeration.
        observer.didFinishEnumeratingChanges(
            upTo: ContactItemSyncAnchor(generationMarker: "<lastChangeGenerationMarker>".data(using: .utf8)!, offset: 0),
            moreComing: false
        )
    }

    func invalidate() async {
        // TODO: Stop the enumeration and cleanup as the extension will be terminated.
    }
}

Wrap up

You can download the full project from https://github.com/0xWDG/ContactProviderDemo.

Conclusion

ContactProvider is a great way to add contacts to the user's address book without interferring with their (personal) address book.
The finish signals are a bit confusing, but once you get the hang of it, it is a great way to add contacts to the user's address book.

Resources:

Read more

Share


Share Mastodon Twitter LinkedIn Facebook
x-twitter mastodon github linkedin discord threads instagram whatsapp bluesky square-rss sitemap