Un workflow di sharing personalizzato con CloudKit
Condividere dati con CloudKit senza UICloudSharingController: un record pubblico conserva l'URL del CKShare e gli amici leggono i dati privati senza dialoghi.
Tradotto dall’inglese con l’aiuto dell’IA e rivisto dall’autore.Leggi l’originale

CloudKit dà grande importanza alla privacy e alla sicurezza degli utenti. Poiché una delle preoccupazioni principali del mio ultimo lavoro, Lookii, riguardava proprio la privacy degli utenti, è stata un'ottima occasione per approfondire le reali potenzialità del framework.
Da iOS 10 CloudKit include una funzionalità integrata per condividere dati privati tra i partecipanti di un container, chiamata CKShare. Purtroppo online ci sono pochissimi articoli sull'argomento e la documentazione di Apple non entra nel dettaglio dei temi avanzati. Tutti gli articoli che ho citato nelle note descrivono il flusso base della condivisione con CloudKit, con alcune varianti interessanti che però non si adattavano esattamente al mio caso. Non tratterò le basi, per le quali rimando alle note. Mi concentrerò invece su un workflow specifico e più avanzato, che potrebbe essere utile a qualcun altro.
Il caso d'uso
Immaginiamo di avere un'applicazione che condivide informazioni degli utenti. Alcune devono restare private ed essere condivise solo tra amici, altre invece devono essere accessibili pubblicamente a tutti i partecipanti del container.
Supponiamo che il nostro tipo di CKRecord sia MyUser e contenga le seguenti informazioni pubbliche:
firstName: String
lastName: String
thumbnail: Data
privateShareUrl: StringAltri dati non saranno pubblici, per ragioni di privacy. Per questi useremo un altro CKRecord, che chiameremo PrivateInfo, con il seguente contenuto:
phoneNumber: String
email: StringStavolta il record non farà parte del database pubblico. Verrà invece salvato in quello privato, accessibile solo al proprietario del record, e inserito in una RecordZone che sarà condivisa con i partecipanti scelti.
Uno schema generale del caso d'uso descritto potrebbe essere questo:

Il workflow generale di CloudKit e i suoi limiti
Come detto, una delle preoccupazioni principali nell'usare il workflow di sharing predefinito di CloudKit riguardava le opzioni di personalizzazione della UI, molto limitate. In particolare il workflow base segue uno schema molto rigido:
Chi condivide deve occuparsi di:
- Creare il CKShare
- Passarlo a una specifica UICloudSharingController
- Creare un URL da condividere con app di terze parti
Gli utenti che vogliono essere aggiunti devono:
- Toccare il link per accettare l'URL condiviso
- Implementare alcuni delegate in UIApplication
Anche se la sua linearità lo rende facilissimo da integrare, questo approccio manca di alcune funzionalità avanzate. Per esempio, non vorrei disturbare gli utenti con flussi complicati che interrompono la naturale esperienza dell'app. Vorrei invece eseguire il workflow il più possibile in background, liberando mittente e destinatario da dialoghi inutili e da cambi di contesto tra un'app e l'altra.
Sarebbe bello poter scegliere i destinatari delle mie informazioni private semplicemente toccando l'icona dell'utente nella mia app.
Implementazione
La prima cosa da fare è abilitare la userDiscoverability tra gli utenti dell'app, altrimenti il resto del processo non funzionerà a causa dei vincoli di privacy.
Per fortuna Apple ha previsto un approccio molto semplice: basta una semplice richiesta di permesso all'utente, che potete inserire nelle prime fasi della vita dell'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")
}
}Una volta ottenuto il permesso dell'utente siamo pronti a partire. Creiamo un CKRecord utente molto semplice (quello che sarà pubblico):
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")
}
})
}Poi, come parte del processo iniziale, dobbiamo creare una zona specifica con cui esporre una vista dei nostri record privati agli utenti scelti (i nostri amici preferiti):
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)
}))
}Infine creiamo un record privato che contiene tutte le nostre informazioni preziose:
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")
}
})
}Notate che il record è legato alla CKRecordZone creata prima.
Come avrete notato, non abbiamo ancora scritto il campo privateShareUrl che fa parte del record pubblico: è la parte cruciale di tutto l'approccio descritto in questo articolo. Il comportamento standard, infatti, si basa sulla UICloudSharingController, che mostra all'utente un dialogo in cui si sceglie cosa e come condividere. Il risultato del dialogo è un URL da usare con app di terze parti come Messaggi, Mail o altro per comunicare l'intenzione di condividere qualcosa.
A peggiorare le cose, dall'altra parte il destinatario deve toccare il link che a sua volta apre la nostra app e chiama il corrispondente metodo delegate di UIApplication. Quest'ultimo ci restituisce alla fine un'istanza di CKShareMetadata, che possiamo usare per leggere le informazioni condivise.
Il nostro approccio
Poiché la nostra applicazione ha già degli utenti, vorrei aggirare questo workflow usando il campo privateShareUrl come punto di ingresso per chiunque abbia bisogno di leggere le informazioni private di uno specifico utente.
Apple dichiara che l'URL di condivisione di un CKShare è stabile e non cambia a meno che non si cambi il root record associato.
Il mio piano è simulare il workflow base di sharing usando il campo privateShareUrl che abbiamo nel database pubblico per ogni utente. Questo campo conterrà l'URL originale del CKShare. In questo modo ogni utente che vuole leggere le informazioni private di uno specifico partecipante accederà all'URL e verificherà se ha il permesso di accedere ai campi privati esposti.
Gli utenti che vogliono condividere le proprie informazioni private con gli amici, invece, possono semplicemente aggiungerli all'URL di condivisione, senza disturbare i destinatari con app di terze parti e messaggi. Ottimo!
La prima cosa da fare è impostare un accesso predefinito pari a nessuno per le nostre informazioni private. In questo modo, nello stato iniziale, nessuno può accedere ai nostri dati privati. Solo quando aggiungiamo qualcuno alla condivisione potrà accedere ai dati. Apple dichiara che il permesso pubblico predefinito di un CKShare è già configurato su nessun accesso.
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)
}
}
}Ora, ogni volta che un utente vuole vedere le informazioni private condivise da un altro, controllerà il privateShareUrl designato per l'utente in questione:
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)
}
}
}
}Semplice ed efficace. Ho messo insieme un'applicazione di prova con un caso d'uso completo, disponibile su GitHub.
Happy Coding! 🖖🏻
