Custom CloudKit sharing workflow

Jun 02, 2018 · 16 mins read · Costantino Pistagna · @valv0

Custom CloudKit sharing workflow

CloudKit has a strong emphasis on users privacy and security. Since one of the main concern for my last work - Lookii - was about users privacy, it was a good chance to investigate in deep about the real potential of the framework.

As of iOS10, CloudKit comes with a built-in feature related to sharing private data among participants on a container, namely CKShare. Unfortunately, there are very few articles about this topic on the internet and Apple documentation does not go in deep with advanced topics. All the relevant articles that I cited in the footnotes cover the basic flow of CloudKit sharing with some interesting variants that, however, didn’t fit exactly my case. I will not cover the basics, for that I will remand to the footnotes. Instead, I will focus on a specific and more advanced workflow that would be useful for someone else.

The use case

Suppose that you have an application that shares users information. Some of those need to be private and shared only among friends, while others should be publicly accessible from every participants in the container.

Let’s suppose that our CKRecord type is MyUser containing the following public information:

firstName: String
lastName: String
thumbnail: Data
privateShareUrl: String

some others data will not be flagged as public due to privacy concerns. For these we will use another CKRecord that we will call PrivateInfo, containing the following:

phoneNumber: String
email: String

This time, the record will not be part of public database. Instead, it will be saved on the private one — accessible exclusively from the owner of the record— and marked as part of a RecordZone that will be shared with selected participants.

A general scheme for the described use case could be something as follow:

Fig.1

CloudKit general workflow and limitations

As stated above, one of the main concern in using CloudKit sharing default workflow was related to the very basic UI customization options. Specifically, the basic workflow uses a very rigid schema:

Who share should be in charge of:

  • Create the CKShare
  • Pass it to a specific UICloudSharingController
  • Create a URL to share using third party apps

Users that want to be added need to:

  • Click on the link for accepting the shared url
  • Implement some delegates on the UIApplication

Even if its straightforwardness makes it super easy to integrate, this approach lacks some advanced features. For example, I wouldn’t bother users with complicated flows that interrupt the natural experience of the app. Instead, I would like to run the workflow much as in the background, freeing source and destination from useless dialogs and app context switching.

It would be great, if I could select receivers of my private information by simple tapping the users icon in my app.

Implementation

The first thing that we need to do is enabling userDiscoverability among app users, otherwise the rest of the process will not work due to privacy constraints.

Thanks god, Apple designed a very easy approach for this task and it requires only a simple access to user permission that you can manage to add in the early stage of your app:

//ask for userDiscoverability otherwise you will obtain nil each time you try to search for him
CKContainer.default().requestApplicationPermission(CKApplicationPermissions.userDiscoverability) { (status, error) in
    switch status {
    case .granted:
        print("granted")
    case .denied:
        print("denied")
    case .initialState:
        print("initial state")
    case .couldNotComplete:
        print("an error as occurred: ", error ?? "Unknown error")
    }
}

Once the user granted his permission we are ready to go. Let’s create a very basic user CKRecord (the one that will be public):

func createPublicRecord() {
    let aRecord = CKRecord(recordType: "MyUser")
    aRecord.setObject("John" as CKRecordValue, forKey: "firstName")
    aRecord.setObject("Appleseed" as CKRecordValue, forKey: "lastName")

    let container = CKContainer.default()
    let publicDatabase = container.publicCloudDatabase
    
    publicDatabase.save(aRecord, completionHandler: { (record, error) -> Void in
        if let error = error {
            print(error)
        }
        else {
            print("record saved successfully")
        }
    })
}

Next, as part of the initial process, we need to create a specific zone that we will use to expose a view of our private records to selected users (our favorite friends):

func createFavZone(completionHandler:@escaping (CKRecordZone?, Error?)->Void) {
    let container = CKContainer.default()
    let privateDatabase = container.privateCloudDatabase
    let customZone = CKRecordZone(zoneName: "FavZone")
    
    privateDatabase.save(customZone, completionHandler: ({returnRecord, error in
        completionHandler(returnRecord, error)
    }))
}

Finally, we will create a private record containing all our valuable information:

func createPrivateRecord() {
    let container = CKContainer.default()
    let privateDatabase = container.privateCloudDatabase

    let recordZone: CKRecordZone = CKRecordZone(zoneName: "FavZone")
    let aRecord = CKRecord(recordType: "PrivateInfo", zoneID: recordZone.zoneID)

    aRecord.setObject("+393331112223" as CKRecordValue, forKey: "phoneNumber")
    aRecord.setObject("john@appleseed.com" as CKRecordValue, forKey: "email")

    
    privateDatabase.save(aRecord, completionHandler: { (record, error) -> Void in
        if let error = error {
            print(error)
        }
        else {
            print("record saved successfully")
        }
    })
}

Note that the record is tied to the CKRecordZone created early.

As you could point out, we still haven’t written the privateShareUrl field that is part of the public record; this is the crucial part of the whole approach described in this article. The standard behaviour, in fact, is based on the UICloudSharingController that will prompt the user with a dialog containing what and how to share the information. The result of the dialog is an URL that we need to use with third party apps such as Message, Email or whatever in order to communicate our intention to share something.

To make things even worst, on the other side, the receiver needs to click the link that, in turn, will open our app calling a corresponding UIApplocation delegate method. The latter, at the end, will give us a CKShareMetadata instance that we can use to fetch shared information.

Our approach

Since our application already contains users, I would like to bypass this workflow by using the privateShareUrl field as an entry point for everyone that needs to fetch private information from a specific user.

Apple states that the share url for CKShare is stable and will not change unless you change the root record associated with it.

My plan is to simulate the basic sharing workflow by using the privateShareUrl field that we have on the public database for each user. This field will contain the original CKShare url. This way, every user that would like to fetch private information from a specific participant will access the url and check if he/she have access permission to the private fields exposed.

On the other hand, users that would like to share their private info with friends can simply add them to the share url, without bothering the receivers with third party apps and messages. Sounds great!

The first thing that we have to do is to create a default none access to our private information. This way, in the initial state no one can access our private data. Only when we add someone to the share, he would be able to access data. Apple, states that the default CKShare public permission is already configured for none access.

func createDefaultShareProfileURL() {
    let container = CKContainer.default()
    let privateDatabase = container.privateCloudDatabase
    
    let query = CKQuery(recordType: "PrivateInfo", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))
    let recordZone: CKRecordZone = CKRecordZone(zoneName: "FavZone")
    
    privateDatabase.perform(query, inZoneWith: recordZone.zoneID) { (results, error) -> Void in
        if let error = error {
            print(error)
        }
        else if let results = results, let ourRecord = results.first {
            let share = CKShare(rootRecord: ourRecord)
            
            let modOp: CKModifyRecordsOperation = CKModifyRecordsOperation(recordsToSave: [ourRecord, share], recordIDsToDelete: nil)
            modOp.savePolicy = .ifServerRecordUnchanged
            modOp.modifyRecordsCompletionBlock = { records, recordIDs, error in
                if let error = error  {
                    print("error in modifying the records: ", error)
                }
                else if let anURL = share.url {
                    let container = CKContainer.default()
                    let publicDatabase = container.publicCloudDatabase
                    let query = CKQuery(recordType: "MyUser", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))
                    var myPublicProfile:CKRecord?
                    
                    publicDatabase.perform(query, inZoneWith: nil) { (results, error) -> Void in
                        if let error = error {
                            print("error: ", error)
                        }
                        else if let results = results {
                            for aRecord in results {
                                if aRecord.creatorUserRecordID?.recordName == "__defaultOwner__" {
                                    myPublicProfile = aRecord
                                    break
                                }
                            }
                        }
                        if let myPublicProfile = myPublicProfile {
                            myPublicProfile.setObject(anURL.absoluteString as CKRecordValue, forKey: "privateShareUrl")
                            publicDatabase.save(myPublicProfile, completionHandler: { (record, error) -> Void in
                                if let error = error {
                                    print("error: ", error)
                                }
                                else {
                                    print("all done, folks!")
                                }
                            })
                        }
                    }
                }
            }
            privateDatabase.add(modOp)
        }
    }
}

Now, every time an user would like to see private info shared by someone else will check the deisgnated privateShareUrl for the pertinent user:

func refreshUserInformation(_ userRecordID: CKRecordID) {
    let container = CKContainer.default()
    let publicDatabase = container.publicCloudDatabase
    
    publicDatabase.fetch(withRecordID: userRecordID) { (record, error) in
        if let error = error {
            print(error)
        }
        else if let record = record {
            let firstName = record.object(forKey: "firstName") as? String
            let lastName = record.object(forKey: "lastName") as? String
            
            //do something with firstName and lastName, updating UI
            
            if let shareURL = record.object(forKey: "privateShareUrl") as? String {
                let sharedDatabase = container.sharedCloudDatabase
                let anURL = URL(string: shareURL)!
                
                let op = CKFetchShareMetadataOperation(shareURLs: [anURL])
                op.perShareMetadataBlock = { shareURL, shareMetadata, error in
                    if let error = error {
                        print(error)
                    }
                    else if let shareMetadata = shareMetadata {
                        if shareMetadata.participantStatus == .accepted {
                            let query = CKQuery(recordType: "PrivateInfo", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))
                            let zone = CKRecordZoneID(zoneName: "FavZone", ownerName: (shareMetadata.ownerIdentity.userRecordID?.recordName)!)
                            sharedDatabase.perform(query, inZoneWith: zone, completionHandler: { (records, error) in
                                if let error = error {
                                    print(error)
                                }
                                else if let records = records, let firstRecord = records.first {
                                    let phoneNumber = firstRecord.object(forKey: "phoneNumber") as? String
                                    let email = firstRecord.object(forKey: "email") as? String
                                    
                                    //do something with private infos
                                }
                            })
                        }
                        else if shareMetadata.participantStatus == .pending {
                            let acceptOp = CKAcceptSharesOperation(shareMetadatas: [shareMetadata])
                            acceptOp.qualityOfService = .userInteractive
                            acceptOp.perShareCompletionBlock = { meta, share, error in
                                if let error = error {
                                    print(error)
                                }
                                else if let share = share {
                                    let query = CKQuery(recordType: "PrivateInfo", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))
                                    let zone = CKRecordZoneID(zoneName: "FavZone", ownerName: (share.owner.userIdentity.userRecordID?.recordName)!)
                                    sharedDatabase.perform(query, inZoneWith: zone, completionHandler: { (records, error) in
                                        if let error = error {
                                            print(error)
                                        }
                                        else if let records = records, let firstRecord = records.first {
                                            let phoneNumber = firstRecord.object(forKey: "phoneNumber") as? String
                                            let email = firstRecord.object(forKey: "email") as? String
                                            
                                            //do something with private infos
                                        }
                                    })
                                }
                            }
                            container.add(acceptOp)
                        }
                    }
                }
                op.fetchShareMetadataCompletionBlock = { error in
                    if let error = error {
                        print(error)
                    }
                }
                container.add(op)
            }
        }
    }
}

Simple and effective. I have assembled a toy application with a complete use-case, available on GitHub.

-Happy Coding! 🖖🏻

Costantino Pistagna
Costantino Pistagna · @valv0 Costantino is a software architect, project manager and consultant with more than ten years of experience in the software industry. He developed and managed projects for universities, medium-sized companies, multi-national corporations, and startups. He is among the first teachers for the Apple's iOS Developer Academy, based in Europe. Regularly, he lectures iOS development around the world, giving students the skills to develop their own high quality apps. While not writing apps, Costantino improves his chefs skills, travelling the world with his beautiful family.