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:
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! 🖖🏻