Initial commit to answer that dude on Reddit

This commit is contained in:
Ryan McGrath 2018-06-04 21:45:04 -07:00
commit 8e5fe962c1
No known key found for this signature in database
GPG key ID: 811674B62B666830
11 changed files with 910 additions and 0 deletions

1
LICENSE Normal file
View file

@ -0,0 +1 @@
Do whatever you want with it. Maybe give me credit for the implementation idea. Up to you. Enjoy. :)

21
readme.md Normal file
View file

@ -0,0 +1,21 @@
# CloudKitSharing
This repository contains code that I was using in an app I opted to discontinue development on. Said app used CloudKit to store records (chat messages, etc) between two or more people. As I was implementing this, I realized...
- How badly CloudKit is documented past the basic stuff
- How utterly broken the existing Sharing flow is
I was originally going to let this lay in my projects folder forever, but then [/u/hrothgar42 happened to ask me how I had implemented the sharing URL functionality in a more user friendly way.](https://www.reddit.com/r/swift/comments/8ivb9y/is_it_possible_to_create_a_shared_database_in/dzx3fxn/)
Thus, I figured the least I could do is dump the code here with a brief explanation of the flow. The license for this is a literal "do whatever you want with it" (and maybe give me some credit for the idea! up to you); CloudKit is honestly an amazing backing layer for apps and I really wish more apps would use it (and stop charging for it... looking at you, Bear!). Maybe this helps developers with that.
## The Flow
- On app launch, configuration flow (in `CloudKitHandler.swift`) runs and ensures that the various zones and what not exist. This is necessary for sharing to work.
- `CloudKitHandler+UserDiscovery.swift` implements logic (that you have to call, see the methods in there) to find other users in your address book that are also using your app. This is all tidied up into some nice block based callbacks.
- `CloudKitHandler+Sharing.swift` is where the real magic happens... and believe me, it's kind of magic because why this was so undocumented I'll never know. When you share a record, it dumps a row into _your public iCloud database_ for that given user's iCloud ID. The configuration blocks mentioned in the first step include an operation to check for invites when a user opens the app. If an invite exists, it grabs the share URL and manually processes accepting it.
With this, there's (very little or no) need for people to be taking a share URL, messaging it to friends, and asking them to accept it (an incredibly cumbersome and error prone process). The approach used in this code could no doubt be refined further, but I simply didn't go deeper on it. Everything in here should be used as reference for building your own - this isn't a "drag and drop" project.
## Questions, Comments, etc
- [ryan@rymc.io](mailto:ryan@rymc.io)
- [@ryanmcgrath on Twitter](https://twitter.com/ryanmcgrath)
- [rymc.io](https://rymc.io/)

View file

@ -0,0 +1,42 @@
//
// CloudKitHandler+ChangeTokens.swift
// Lychee
//
// Created by Ryan McGrath on 5/5/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
/**
* This extension handles converting and archiving CKServerChangeToken(s) as necessary.
* Store them (securely) in NSUserDefaults as the easiest way, ensures extensions can grab
* them as well.
*/
extension CloudKitHandler {
public var changeToken: CKServerChangeToken? {
get {
if(backingPreviousChangeToken == nil) {
guard let defaults: UserDefaults = UserDefaults(suiteName: RYMC_APP_GROUP_ID) else { return nil }
guard let data: Data = defaults.data(forKey: RYMC_CK_PREVIOUS_SERVER_CHANGE_TOKEN) else { return nil }
let unarchiver: NSKeyedUnarchiver = NSKeyedUnarchiver(forReadingWith: data)
unarchiver.requiresSecureCoding = true
backingPreviousChangeToken = CKServerChangeToken(coder: unarchiver)
}
return backingPreviousChangeToken
}
set(value) {
backingPreviousChangeToken = value
guard let value = value else { return }
guard let defaults: UserDefaults = UserDefaults(suiteName: RYMC_APP_GROUP_ID) else { return }
let data: NSMutableData = NSMutableData()
let archiver: NSKeyedArchiver = NSKeyedArchiver(forWritingWith: data)
archiver.requiresSecureCoding = true
value.encode(with: archiver)
archiver.finishEncoding()
defaults.setValue(data, forKey: RYMC_CK_PREVIOUS_SERVER_CHANGE_TOKEN)
// defaults.synchronize() -- not necessary in 99% of cases.
}
}
}

View file

@ -0,0 +1,190 @@
//
// CloudKitHandler+Errors.swift
// Lychee
//
// Created by Ryan McGrath on 5/5/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
public struct CloudKitErrorHandler {
/// We could classify all the result that CKOperation returns into the following five CKOperationResultTypes
public enum CKOperationResultType {
case success
case retry(afterSeconds: Double, message: String)
case chunk
case recoverableError(reason: CKOperationFailReason, message: String)
case fail(reason: CKOperationFailReason, message: String)
}
/// The reason of CloudKit failure could be classified into following 8 cases
public enum CKOperationFailReason {
case changeTokenExpired
case network
case quotaExceeded
case partialFailure
case serverRecordChanged
case shareRelated
case unhandledErrorCode
case unknown
}
public func resultType(with error: Error?) -> CKOperationResultType {
guard error != nil else { return .success }
guard let e = error as? CKError else {
return .fail(reason: .unknown, message: "The error returned is not a CKError")
}
let message = returnErrorMessage(for: e.code)
switch e.code {
// SHOULD RETRY
case .serviceUnavailable,
.requestRateLimited,
.zoneBusy:
// If there is a retry delay specified in the error, then use that.
let userInfo = e.userInfo
if let retry = userInfo[CKErrorRetryAfterKey] as? Double {
print("ErrorHandler - \(message). Should retry in \(retry) seconds.")
return .retry(afterSeconds: retry, message: message)
} else {
return .fail(reason: .unknown, message: message)
}
// RECOVERABLE ERROR
case .networkUnavailable,
.networkFailure:
print("ErrorHandler.recoverableError: \(message)")
return .recoverableError(reason: .network, message: message)
case .changeTokenExpired:
print("ErrorHandler.recoverableError: \(message)")
return .recoverableError(reason: .changeTokenExpired, message: message)
case .serverRecordChanged:
print("ErrorHandler.recoverableError: \(message)")
return .recoverableError(reason: .serverRecordChanged, message: message)
case .partialFailure:
// Normally it shouldn't happen since if CKOperation `isAtomic` set to true
if let dictionary = e.userInfo[CKPartialErrorsByItemIDKey] as? NSDictionary {
print("ErrorHandler.partialFailure for \(dictionary.count) items; CKPartialErrorsByItemIDKey: \(dictionary)")
}
return .recoverableError(reason: .partialFailure, message: message)
// SHOULD CHUNK IT UP
case .limitExceeded:
print("ErrorHandler.Chunk: \(message)")
return .chunk
// SHARE DATABASE RELATED
case .alreadyShared,
.participantMayNeedVerification,
.referenceViolation,
.tooManyParticipants:
print("ErrorHandler.Fail: \(message)")
return .fail(reason: .shareRelated, message: message)
// quota exceeded is sort of a special case where the user has to take action(like spare more room in iCloud) before retry
case .quotaExceeded:
print("ErrorHandler.Fail: \(message)")
return .fail(reason: .quotaExceeded, message: message)
// FAIL IS THE FINAL, WE REALLY CAN'T DO MORE
default:
print("ErrorHandler.Fail: \(message)")
return .fail(reason: .unknown, message: message)
}
}
public func retryOperation(after: Double, block: @escaping () -> ()) {
let delayTime = DispatchTime.now() + after
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: block)
}
private func returnErrorMessage(for code: CKError.Code) -> String {
var returnMessage = ""
switch code {
case .alreadyShared:
returnMessage = "Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares."
case .assetFileModified:
returnMessage = "Asset File Modified: the content of the specified asset file was modified while being saved."
case .assetFileNotFound:
returnMessage = "Asset File Not Found: the specified asset file is not found."
case .badContainer:
returnMessage = "Bad Container: the specified container is unknown or unauthorized."
case .badDatabase:
returnMessage = "Bad Database: the operation could not be completed on the given database."
case .batchRequestFailed:
returnMessage = "Batch Request Failed: the entire batch was rejected."
case .changeTokenExpired:
returnMessage = "Change Token Expired: the previous server change token is too old."
case .constraintViolation:
returnMessage = "Constraint Violation: the server rejected the request because of a conflict with a unique field."
case .incompatibleVersion:
returnMessage = "Incompatible Version: your app version is older than the oldest version allowed."
case .internalError:
returnMessage = "Internal Error: a nonrecoverable error was encountered by CloudKit."
case .invalidArguments:
returnMessage = "Invalid Arguments: the specified request contains bad information."
case .limitExceeded:
returnMessage = "Limit Exceeded: the request to the server is too large."
case .managedAccountRestricted:
returnMessage = "Managed Account Restricted: the request was rejected due to a managed-account restriction."
case .missingEntitlement:
returnMessage = "Missing Entitlement: the app is missing a required entitlement."
case .networkUnavailable:
returnMessage = "Network Unavailable: the internet connection appears to be offline."
case .networkFailure:
returnMessage = "Network Failure: the internet connection appears to be offline."
case .notAuthenticated:
returnMessage = "Not Authenticated: to use this app, you must enable iCloud syncing. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud feature is enabled."
case .operationCancelled:
returnMessage = "Operation Cancelled: the operation was explicitly canceled."
case .partialFailure:
returnMessage = "Partial Failure: some items failed, but the operation succeeded overall."
case .participantMayNeedVerification:
returnMessage = "Participant May Need Verification: you are not a member of the share."
case .permissionFailure:
returnMessage = "Permission Failure: to use this app, you must enable iCloud syncing. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud feature is enabled."
case .quotaExceeded:
returnMessage = "Quota Exceeded: saving would exceed your current iCloud storage quota."
case .referenceViolation:
returnMessage = "Reference Violation: the target of a record's parent or share reference was not found."
case .requestRateLimited:
returnMessage = "Request Rate Limited: transfers to and from the server are being rate limited at this time."
case .serverRecordChanged:
returnMessage = "Server Record Changed: the record was rejected because the version on the server is different."
case .serverRejectedRequest:
returnMessage = "Server Rejected Request"
case .serverResponseLost:
returnMessage = "Server Response Lost"
case .serviceUnavailable:
returnMessage = "Service Unavailable: Please try again."
case .tooManyParticipants:
returnMessage = "Too Many Participants: a share cannot be saved because too many participants are attached to the share."
case .unknownItem:
returnMessage = "Unknown Item: the specified record does not exist."
case .userDeletedZone:
returnMessage = "User Deleted Zone: the user has deleted this zone from the settings UI."
case .zoneBusy:
returnMessage = "Zone Busy: the server is too busy to handle the zone operation."
case .zoneNotFound:
returnMessage = "Zone Not Found: the specified record zone does not exist on the server."
default:
returnMessage = "Unhandled Error."
}
return returnMessage + "CKError.Code: \(code.rawValue)"
}
}
extension Array where Element: CKRecord {
func chunkItUp(by chunkSize: Int) -> [[Element]] {
return stride(from: 0, to: count, by: chunkSize).map({ (startIndex) -> [Element] in
let endIndex = (startIndex.advanced(by: chunkSize) > count) ? count : (startIndex + chunkSize)
return Array(self[startIndex..<endIndex])
})
}
}

View file

@ -0,0 +1,21 @@
//
// CloudKitHandler+Realm.swift
// Lychee
//
// Created by Ryan McGrath on 5/12/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
private var messagesNotificationToken: RLMNotificationToken?
extension CloudKitHandler {
func startMonitoringDatabase() {
messagesNotificationToken = ChatMessage.allObjects().addNotificationBlock { [unowned self] (results: RLMResults?, change: RLMCollectionChange?, err: Error?) in
}
}
func endMonitoringDatabase() {
messagesNotificationToken?.invalidate()
}
}

View file

@ -0,0 +1,102 @@
//
// CloudKitHandler+Records.swift
// Lychee
//
// Created by Ryan McGrath on 5/8/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
extension CloudKitHandler {
func rootRecordID() -> CKRecordID {
return CKRecordID(recordName: "Profile", zoneID: defaultRecordZone().zoneID)
}
public var rootRecord: CKRecord? {
get {
if(backingRootRecord == nil) {
backingRootRecord = CKRecord(recordType: "Profile", recordID: rootRecordID())
}
return backingRootRecord
}
set(value) { backingRootRecord = value }
}
func fetchRootRecordOperation(cancelIfExistsAlready: [Operation]) -> CKFetchRecordsOperation {
let recordID: CKRecordID = rootRecordID()
let operation: CKFetchRecordsOperation = CKFetchRecordsOperation(recordIDs: [recordID])
operation.qualityOfService = .userInitiated
operation.database = container.privateCloudDatabase
operation.fetchRecordsCompletionBlock = { [unowned self] (records: [CKRecordID: CKRecord]?, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .success:
if(records != nil && records?[recordID] != nil) {
self.rootRecord = records![recordID]!
for operation: Operation in cancelIfExistsAlready { operation.cancel() }
}
return
case .retry:
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
case .recoverableError(let reason, _):
// This is an oddity of CloudKit, but should be handled. It's complaining that the
// record doesn't exist. In our case, we want to just move right along.
if(reason == CloudKitErrorHandler.CKOperationFailReason.partialFailure) {
return
}
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return operation
}
func createRootRecordOperation() -> CKModifyRecordsOperation {
let operation: CKModifyRecordsOperation = CKModifyRecordsOperation(recordsToSave: [rootRecord!], recordIDsToDelete: nil)
operation.qualityOfService = .userInitiated
operation.database = container.privateCloudDatabase
operation.modifyRecordsCompletionBlock = { [unowned self] (records: [CKRecord]?, _, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .success:
return
case .retry, .recoverableError(_, _):
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return operation
}
typealias modifyRecordsCompleteBlock = ([CKRecord]?, [CKRecordID]?, Error?) -> Void
public func modifyRecordsOperation(save: [CKRecord]?, delete: [CKRecordID]?, _ complete: @escaping modifyRecordsCompleteBlock) -> CKModifyRecordsOperation {
let operation: CKModifyRecordsOperation = CKModifyRecordsOperation(recordsToSave: save, recordIDsToDelete: delete)
operation.qualityOfService = .userInitiated
operation.database = container.privateCloudDatabase
operation.modifyRecordsCompletionBlock = complete
return operation
}
}

View file

@ -0,0 +1,157 @@
//
// CloudKitHandler+Sharing.swift
// Lychee
//
// Created by Ryan McGrath on 5/8/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
extension CloudKitHandler {
public func acceptShares(_ shareMetadatas: [CKShareMetadata]) {
let operation: CKAcceptSharesOperation = CKAcceptSharesOperation(shareMetadatas: shareMetadatas)
operation.qualityOfService = .userInitiated
operation.perShareCompletionBlock = { [unowned self] (shareMetaData: CKShareMetadata, acceptedShare: CKShare?, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .retry, .recoverableError(_, _):
self.errorHandler.retryOperation(after: 30, block: {
self.acceptShares(shareMetadatas)
})
return
default:
return
}
}
operation.acceptSharesCompletionBlock = { [unowned self] (err: Error?) in
print("ACCEPTED SHARES!!!!")
switch self.errorHandler.resultType(with: err) {
case .retry, .recoverableError(_, _):
self.errorHandler.retryOperation(after: 30, block: {
self.acceptShares(shareMetadatas)
})
return
default:
return
}
}
addOperations([operation])
}
typealias shareCompleteBlock = (CKShare?, Error?) -> Void
// @TODO: This convenience method can be much safer.
public func shareRootRecord(with users: [ShareableUser], _ complete: @escaping shareCompleteBlock) {
share(record: rootRecord!, with: users, complete)
}
public func share(record: CKRecord, with users: [ShareableUser], _ complete: @escaping shareCompleteBlock) {
let share: CKShare = CKShare(rootRecord: record)
share[CKShareTitleKey] = "Lychee" as CKRecordValue
share[CKShareTypeKey] = "com.rymc.Lychee" as CKRecordValue
let identities: [CKUserIdentityLookupInfo] = users.map { (user: ShareableUser) -> CKUserIdentityLookupInfo in
return CKUserIdentityLookupInfo(userRecordID: user.identity.userRecordID!)
}
let shareOperation: CKFetchShareParticipantsOperation = CKFetchShareParticipantsOperation(userIdentityLookupInfos: identities)
shareOperation.qualityOfService = .userInitiated
shareOperation.shareParticipantFetchedBlock = { (participant: CKShareParticipant) in
participant.permission = .readOnly
share.addParticipant(participant)
}
let saveOperation: CKModifyRecordsOperation = modifyRecordsOperation(save: [record, share], delete: nil) { [unowned self] (savedRecords: [CKRecord]?, deletedRecordIDs: [CKRecordID]?, error: Error?) in
switch self.errorHandler.resultType(with: error) {
case .success:
DispatchQueue.main.async { complete(share, error) }
return
case .retry, .recoverableError(_, _):
self.errorHandler.retryOperation(after: 30, block: {
self.share(record: record, with: users, complete)
})
return
default:
return
}
}
shareOperation.fetchShareParticipantsCompletionBlock = { [unowned self] (shareOperationError: Error?) in
switch self.errorHandler.resultType(with: shareOperationError) {
case .retry, .recoverableError(_, _):
saveOperation.cancel()
self.errorHandler.retryOperation(after: 30, block: {
self.share(record: record, with: users, complete)
})
return
default:
return
}
}
saveOperation.addDependency(shareOperation)
addOperations([shareOperation, saveOperation])
}
public func saveToPublicDatabase(users: [ShareableUser], share: CKShare) {
let recordID: CKRecordID = CKRecordID(recordName: NSUUID().uuidString)
let record: CKRecord = CKRecord(recordType: "Share", recordID: recordID)
record["url"] = share.url?.absoluteString as CKRecordValue?
record["userID"] = users[0].identity.userRecordID?.recordName as CKRecordValue?
let op: CKModifyRecordsOperation = CKModifyRecordsOperation(recordsToSave: [
record
], recordIDsToDelete: [])
op.qualityOfService = .userInitiated
op.database = container.publicCloudDatabase
op.modifyRecordsCompletionBlock = { (_, _, err: Error?) in
if(err != nil) { print("Error saving share to public DB: \(String(describing: err))") }
}
addOperations([op])
}
public func checkForInvitesOperation() -> CKQueryOperation {
let query: CKQuery = CKQuery(recordType: "Share", predicate: NSPredicate(format: "userID = %@", userRecordID!))
let op: CKQueryOperation = CKQueryOperation(query: query)
op.database = container.publicCloudDatabase
op.recordFetchedBlock = { [unowned self] (record: CKRecord) in
let url: String = record["url"] as! String
print("URL: \(url)")
guard let shareURL = URL(string: url) else { return }
self.pendingInvites.append(shareURL)
}
op.queryCompletionBlock = { (_, err: Error?) in
if(err != nil) { print("Error retrieving urls: \(String(describing: err))") }
print("COMPLETED?!")
}
return op
}
public func fetchShareMetadatas() -> CKFetchShareMetadataOperation {
print("HERE NOW?!")
let op: CKFetchShareMetadataOperation = CKFetchShareMetadataOperation(shareURLs: pendingInvites)
op.qualityOfService = .userInitiated
op.perShareMetadataBlock = { [unowned self] (url: URL, metadata: CKShareMetadata?, err: Error?) in
if(err != nil) { print("Error retrieving metadatas: \(String(describing: err))") }
guard let metadata = metadata else { return }
print("Metadata: \(String(describing: metadata))")
self.pendingMetadatas.append(metadata)
}
op.fetchShareMetadataCompletionBlock = { [unowned self] (err: Error?) in
self.acceptShares(self.pendingMetadatas)
}
return op
}
}

View file

@ -0,0 +1,84 @@
//
// CloudKitHandler+Subscriptions.swift
// Lychee
//
// Created by Ryan McGrath on 5/3/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
extension CloudKitHandler {
func fetchSubscriptionsOperation(_ scope: CKDatabaseScope, cancelIfExistsAlready: [Operation]) -> CKFetchSubscriptionsOperation {
let operation: CKFetchSubscriptionsOperation = CKFetchSubscriptionsOperation.fetchAllSubscriptionsOperation()
operation.qualityOfService = .userInitiated
operation.database = container.database(with: scope)
operation.fetchSubscriptionCompletionBlock = { (subscriptions: [String: CKSubscription]?, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .success:
guard let subscriptions = subscriptions else { return }
if(subscriptions[RYMC_CKSUBSCRIPTION_NAME] != nil) {
for operation: Operation in cancelIfExistsAlready {
operation.cancel()
}
}
return
case .retry, .recoverableError(_, _):
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return operation;
}
func createSubscriptionOperation(_ scope: CKDatabaseScope) -> CKModifySubscriptionsOperation {
let operation: CKModifySubscriptionsOperation = CKModifySubscriptionsOperation(subscriptionsToSave: [
scope == .shared ? sharedDatabaseSubscription() : privateDatabaseSubscription()
], subscriptionIDsToDelete: [])
operation.qualityOfService = .userInitiated
operation.database = container.database(with: scope)
operation.modifySubscriptionsCompletionBlock = { (subscriptions: [CKSubscription]?, _, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .retry, .recoverableError(_, _):
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return operation
}
private final func privateDatabaseSubscription() -> CKSubscription {
let recordZone: CKRecordZone = defaultRecordZone()
let subscription: CKRecordZoneSubscription = CKRecordZoneSubscription(zoneID: recordZone.zoneID, subscriptionID: RYMC_CKSUBSCRIPTION_NAME)
subscription.notificationInfo = notificationInfo("private")
return subscription
}
private final func sharedDatabaseSubscription() -> CKDatabaseSubscription {
let subscription: CKDatabaseSubscription = CKDatabaseSubscription(subscriptionID: RYMC_CKSUBSCRIPTION_NAME)
subscription.notificationInfo = notificationInfo("shared")
subscription.notificationInfo?.desiredKeys = []
return subscription
}
final func notificationInfo(_ category: String) -> CKNotificationInfo {
let info: CKNotificationInfo = CKNotificationInfo()
info.soundName = "default"
info.shouldSendContentAvailable = true
info.category = category
return info
}
}

View file

@ -0,0 +1,102 @@
//
// CloudKitHandler+UserDiscoverability.swift
// Lychee
//
// Created by Ryan McGrath on 5/8/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
final class ShareableUser: RYMCModel {
let contact: CNContact
let identity: CKUserIdentity
init(contact: CNContact, identity: CKUserIdentity) {
self.contact = contact
self.identity = identity
}
}
struct ShareablePermissionsGranted {
let contacts: Bool
let discoverability: Bool
}
extension CloudKitHandler {
typealias discoverUsersCompleteBlock = (ShareablePermissionsGranted, [ShareableUser], Error?) -> Void
public func discoverUsers(_ complete: @escaping discoverUsersCompleteBlock) {
let store: CNContactStore = CNContactStore()
store.requestAccess(for: .contacts) { [unowned self] (granted: Bool, error: Error?) in
let shareGranted: ShareablePermissionsGranted = ShareablePermissionsGranted(contacts: granted, discoverability: false)
if(!granted) {
return complete(shareGranted, [], nil)
}
if(error != nil) {
print("Error fetching contacts store: \(String(describing: error))")
return complete(shareGranted, [], error)
}
let contacts: [CNContact] = self.loadContacts(from: store)
self.container.requestApplicationPermission(.userDiscoverability) { (status: CKApplicationPermissionStatus, statusError: Error?) in
if(status != .granted) { return complete(shareGranted, [], nil) }
if(error != nil) {
let discoverabilityGranted: ShareablePermissionsGranted = ShareablePermissionsGranted(contacts: true, discoverability: true)
print("Error requesting user discoverability application permission: \(String(describing: error))")
return complete(discoverabilityGranted, [], statusError)
}
self.discoverAndMatchUserIdentities(with: contacts, complete)
}
}
}
private func loadContacts(from store: CNContactStore) -> [CNContact] {
let keys: [CNKeyDescriptor] = [
CNContactImageDataKey as CNKeyDescriptor,
CNContactThumbnailImageDataKey as CNKeyDescriptor,
CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
]
var containers: [CNContainer] = []
do { containers = try store.containers(matching: nil) } catch {
print("Could not find containers for contacts! \(error)")
}
var contacts: [CNContact] = []
for container: CNContainer in containers {
let predicate: NSPredicate = CNContact.predicateForContactsInContainer(withIdentifier: container.identifier)
do {
let unifiedContacts: [CNContact] = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
contacts.append(contentsOf: unifiedContacts)
} catch {
print("Error getting unified contacts! \(error)")
}
}
return contacts
}
private func discoverAndMatchUserIdentities(with contacts: [CNContact], _ complete: @escaping discoverUsersCompleteBlock) {
let op: CKDiscoverAllUserIdentitiesOperation = CKDiscoverAllUserIdentitiesOperation()
var users: [ShareableUser] = []
op.userIdentityDiscoveredBlock = { (identity: CKUserIdentity) in
for contact: CNContact in contacts {
if(identity.contactIdentifiers.contains(contact.identifier)) {
users.append(ShareableUser(contact: contact, identity: identity))
break
}
}
}
op.discoverAllUserIdentitiesCompletionBlock = { (err: Error?) in
let permissions: ShareablePermissionsGranted = ShareablePermissionsGranted(contacts: true, discoverability: true)
complete(permissions, users, err)
}
container.add(op)
}
}

View file

@ -0,0 +1,63 @@
//
// CloudKitHandler+Zones.swift
// Lychee
//
// Created by Ryan McGrath on 5/3/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
extension CloudKitHandler {
func defaultRecordZone() -> CKRecordZone {
return CKRecordZone(zoneName: RYMC_CKZONE_NAME)
}
func fetchPrivateZonesOperation(cancelIfExistsAlready: [Operation]) -> CKFetchRecordZonesOperation {
let recordZone: CKRecordZone = defaultRecordZone()
let op: CKFetchRecordZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
op.qualityOfService = .userInitiated
op.database = container.privateCloudDatabase
op.fetchRecordZonesCompletionBlock = { [unowned self] (z: [CKRecordZoneID: CKRecordZone]?, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .success:
if(z != nil && z?[recordZone.zoneID] != nil) {
for operation: Operation in cancelIfExistsAlready { operation.cancel() }
}
return
case .retry, .recoverableError(_, _):
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return op
}
func createPrivateZoneOperation() -> CKModifyRecordZonesOperation {
let recordZone: CKRecordZone = defaultRecordZone()
let op: CKModifyRecordZonesOperation = CKModifyRecordZonesOperation(recordZonesToSave: [recordZone], recordZoneIDsToDelete: nil)
op.qualityOfService = .userInitiated
op.database = container.privateCloudDatabase
op.modifyRecordZonesCompletionBlock = { (_, _, err: Error?) in
switch self.errorHandler.resultType(with: err) {
case .retry, .recoverableError(_, _):
self.stopAllOperations()
self.errorHandler.retryOperation(after: 30, block: {
self.configure()
})
return
default:
return
}
}
return op
}
}

127
src/CloudKitHandler.swift Normal file
View file

@ -0,0 +1,127 @@
//
// CloudKitHandler.swift
// Lychee
//
// Created by Ryan McGrath on 5/3/18.
// Copyright © 2018 Ryan McGrath. All rights reserved.
//
/**
* CloudKitHandler
*
* Handles communicating with CloudKit and the associated APIs. The CloudKit APIs can be a bit
* unwieldy and verbose, so this library aims to work around those limitations and streamline
* everything.
*/
class CloudKitHandler {
static let shared = CloudKitHandler()
var isConfiguring: Bool = true
var isSyncing: Bool = false
let errorHandler: CloudKitErrorHandler = CloudKitErrorHandler()
var accountStatus: CKAccountStatus = .noAccount
var container: CKContainer = CKContainer(identifier: RYMC_CLOUDKIT_CONTAINER_ID)
var userRecordID: String?
var pendingInvites: [URL] = []
var pendingMetadatas: [CKShareMetadata] = []
private var operationQueue: OperationQueue = OperationQueue()
private var queuedOperations: [CKOperation] = []
// Internal
var backingPreviousChangeToken: CKServerChangeToken?
var backingRootRecord: CKRecord?
init() {
NotificationCenter.default.addObserver(self, selector: #selector(onAccountChanged), name: NSNotification.Name.CKAccountChanged, object: nil)
}
@objc func onAccountChanged() {
isConfiguring = true
isSyncing = false
determineAccountStatus()
}
public func determineAccountStatus() {
container.accountStatus { [unowned self] (status: CKAccountStatus, error: Error?) in
if(error != nil) {
print("Error retrieving account status \(String(describing: error))")
self.finishConfiguration(successful: false)
return
}
if(status != CKAccountStatus.available) {
// error
self.finishConfiguration(successful: false)
return
}
self.container.fetchUserRecordID(completionHandler: { (recordID: CKRecordID?, err: Error?) in
guard let recordID = recordID else {
self.finishConfiguration(successful: false)
return
}
print("User record ID: \(String(describing: recordID.recordName))")
self.userRecordID = recordID.recordName
self.accountStatus = status
self.configure()
})
}
}
func configure() {
let createZone: CKModifyRecordZonesOperation = createPrivateZoneOperation()
let fetchZones: CKFetchRecordZonesOperation = fetchPrivateZonesOperation(cancelIfExistsAlready: [createZone])
createZone.addDependency(fetchZones)
let createPrivateSub: CKModifySubscriptionsOperation = createSubscriptionOperation(.private)
let fetchPrivateSub: CKFetchSubscriptionsOperation = fetchSubscriptionsOperation(.private, cancelIfExistsAlready: [createPrivateSub])
fetchPrivateSub.addDependency(createZone)
createPrivateSub.addDependency(fetchPrivateSub)
let createSharedSub: CKModifySubscriptionsOperation = createSubscriptionOperation(.shared)
let fetchSharedSub: CKFetchSubscriptionsOperation = fetchSubscriptionsOperation(.shared, cancelIfExistsAlready: [createSharedSub])
fetchSharedSub.addDependency(createZone)
createSharedSub.addDependency(fetchSharedSub)
let createRootRecord: CKModifyRecordsOperation = createRootRecordOperation()
let fetchRootRecord: CKFetchRecordsOperation = fetchRootRecordOperation(cancelIfExistsAlready: [createRootRecord])
fetchRootRecord.addDependency(createZone)
createRootRecord.addDependency(fetchRootRecord)
let complete: BlockOperation = BlockOperation(block: { [unowned self] in
self.finishConfiguration()
})
complete.addDependency(createRootRecord)
operationQueue.addOperations([
fetchZones, createZone,
fetchPrivateSub, createPrivateSub,
fetchSharedSub, createSharedSub,
fetchRootRecord, createRootRecord,
complete
], waitUntilFinished: false)
}
func finishConfiguration(successful: Bool = true) {
isConfiguring = false
operationQueue.addOperations(queuedOperations, waitUntilFinished: false)
queuedOperations.removeAll()
}
func addOperations(_ operations: [CKOperation]) {
if(isConfiguring) {
queuedOperations.append(contentsOf: operations)
} else {
operationQueue.addOperations(operations, waitUntilFinished: false)
}
}
func stopAllOperations() {
for operation: Operation in operationQueue.operations.reversed() {
operation.cancel()
}
}
}