Initial commit to answer that dude on Reddit
This commit is contained in:
commit
8e5fe962c1
11 changed files with 910 additions and 0 deletions
1
LICENSE
Normal file
1
LICENSE
Normal 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
21
readme.md
Normal 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/)
|
||||
42
src/CloudKitHandler+ChangeTokens.swift
Normal file
42
src/CloudKitHandler+ChangeTokens.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/CloudKitHandler+Errors.swift
Normal file
190
src/CloudKitHandler+Errors.swift
Normal 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])
|
||||
})
|
||||
}
|
||||
}
|
||||
21
src/CloudKitHandler+Realm.swift
Normal file
21
src/CloudKitHandler+Realm.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
102
src/CloudKitHandler+Records.swift
Normal file
102
src/CloudKitHandler+Records.swift
Normal 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
|
||||
}
|
||||
}
|
||||
157
src/CloudKitHandler+Sharing.swift
Normal file
157
src/CloudKitHandler+Sharing.swift
Normal 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
|
||||
}
|
||||
}
|
||||
84
src/CloudKitHandler+Subscriptions.swift
Normal file
84
src/CloudKitHandler+Subscriptions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
102
src/CloudKitHandler+UserDiscoverability.swift
Normal file
102
src/CloudKitHandler+UserDiscoverability.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
63
src/CloudKitHandler+Zones.swift
Normal file
63
src/CloudKitHandler+Zones.swift
Normal 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
127
src/CloudKitHandler.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue