From f288e3d5d8e179afe34825ed9e7a1ffa3ad6af53 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 27 Mar 2020 13:59:42 -0500 Subject: [PATCH] More infrastructure work on CloudKit. --- .../Account/Account.xcodeproj/project.pbxproj | 12 +- .../Account/CloudKit/CKError+Extensions.swift | 87 ++++++++ .../CloudKit/CloudKitAccountZone.swift | 49 +---- .../CloudKit/CloudKitErrorHandler.swift | 194 ------------------ .../Account/CloudKit/CloudKitResult.swift | 53 +++++ .../Account/CloudKit/CloudKitZone.swift | 105 ++++++++-- .../Account/CloudKit/WebFeed+CloudKit.swift | 4 +- 7 files changed, 245 insertions(+), 259 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CKError+Extensions.swift delete mode 100644 Frameworks/Account/CloudKit/CloudKitErrorHandler.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitResult.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index b8ceac19a..755ee05fe 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; + 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitResult.swift */; }; + 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; }; 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; }; 51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; }; 51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; }; @@ -64,7 +66,6 @@ 51E148EC234B8FFC0004F7A5 /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */; }; 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB40229AF61B00645299 /* AccountError.swift */; }; 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; - 51E4DB2C242632DC0091EB5B /* CloudKitErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */; }; 51E4DB2E242633ED0091EB5B /* CloudKitZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */; }; 51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */; }; 51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */; }; @@ -289,6 +290,8 @@ 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; + 51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = ""; }; + 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = ""; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = ""; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = ""; }; 51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = ""; }; @@ -297,7 +300,6 @@ 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyncDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 51E3EB40229AF61B00645299 /* AccountError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountError.swift; sourceTree = ""; }; 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; - 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorHandler.swift; sourceTree = ""; }; 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = ""; }; 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = ""; }; 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = ""; }; @@ -515,10 +517,11 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( + 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, - 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */, 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */, + 51C034DE242D65D20014DC71 /* CloudKitResult.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */, 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */, @@ -1171,6 +1174,7 @@ 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, + 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, @@ -1185,7 +1189,6 @@ 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, - 51E4DB2C242632DC0091EB5B /* CloudKitErrorHandler.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, @@ -1193,6 +1196,7 @@ 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, + 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CKError+Extensions.swift b/Frameworks/Account/CloudKit/CKError+Extensions.swift new file mode 100644 index 000000000..cdce8cdfb --- /dev/null +++ b/Frameworks/Account/CloudKit/CKError+Extensions.swift @@ -0,0 +1,87 @@ +// +// CKError+Extensions.swift +// Account +// +// Created by Maurice Parker on 3/26/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension CKError: LocalizedError { + + public var errorDescription: String? { + switch code { + case .alreadyShared: + return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error") + case .assetFileModified: + return NSLocalizedString("Asset File Modified: the content of the specified asset file was modified while being saved.", comment: "Known iCloud Error") + case .assetFileNotFound: + return NSLocalizedString("Asset File Not Found: the specified asset file is not found.", comment: "Known iCloud Error") + case .badContainer: + return NSLocalizedString("Bad Container: the specified container is unknown or unauthorized.", comment: "Known iCloud Error") + case .badDatabase: + return NSLocalizedString("Bad Database: the operation could not be completed on the given database.", comment: "Known iCloud Error") + case .batchRequestFailed: + return NSLocalizedString("Batch Request Failed: the entire batch was rejected.", comment: "Known iCloud Error") + case .changeTokenExpired: + return NSLocalizedString("Change Token Expired: the previous server change token is too old.", comment: "Known iCloud Error") + case .constraintViolation: + return NSLocalizedString("Constraint Violation: the server rejected the request because of a conflict with a unique field.", comment: "Known iCloud Error") + case .incompatibleVersion: + return NSLocalizedString("Incompatible Version: your app version is older than the oldest version allowed.", comment: "Known iCloud Error") + case .internalError: + return NSLocalizedString("Internal Error: a nonrecoverable error was encountered by CloudKit.", comment: "Known iCloud Error") + case .invalidArguments: + return NSLocalizedString("Invalid Arguments: the specified request contains bad information.", comment: "Known iCloud Error") + case .limitExceeded: + return NSLocalizedString("Limit Exceeded: the request to the server is too large.", comment: "Known iCloud Error") + case .managedAccountRestricted: + return NSLocalizedString("Managed Account Restricted: the request was rejected due to a managed-account restriction.", comment: "Known iCloud Error") + case .missingEntitlement: + return NSLocalizedString("Missing Entitlement: the app is missing a required entitlement.", comment: "Known iCloud Error") + case .networkUnavailable: + return NSLocalizedString("Network Unavailable: the internet connection appears to be offline.", comment: "Known iCloud Error") + case .networkFailure: + return NSLocalizedString("Network Failure: the internet connection appears to be offline.", comment: "Known iCloud Error") + case .notAuthenticated: + return NSLocalizedString("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.", comment: "Known iCloud Error") + case .operationCancelled: + return NSLocalizedString("Operation Cancelled: the operation was explicitly canceled.", comment: "Known iCloud Error") + case .partialFailure: + return NSLocalizedString("Partial Failure: some items failed, but the operation succeeded overall.", comment: "Known iCloud Error") + case .participantMayNeedVerification: + return NSLocalizedString("Participant May Need Verification: you are not a member of the share.", comment: "Known iCloud Error") + case .permissionFailure: + return NSLocalizedString("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.", comment: "Known iCloud Error") + case .quotaExceeded: + return NSLocalizedString("Quota Exceeded: saving would exceed your current iCloud storage quota.", comment: "Known iCloud Error") + case .referenceViolation: + return NSLocalizedString("Reference Violation: the target of a record's parent or share reference was not found.", comment: "Known iCloud Error") + case .requestRateLimited: + return NSLocalizedString("Request Rate Limited: transfers to and from the server are being rate limited at this time.", comment: "Known iCloud Error") + case .serverRecordChanged: + return NSLocalizedString("Server Record Changed: the record was rejected because the version on the server is different.", comment: "Known iCloud Error") + case .serverRejectedRequest: + return NSLocalizedString("Server Rejected Request", comment: "Known iCloud Error") + case .serverResponseLost: + return NSLocalizedString("Server Response Lost", comment: "Known iCloud Error") + case .serviceUnavailable: + return NSLocalizedString("Service Unavailable: Please try again.", comment: "Known iCloud Error") + case .tooManyParticipants: + return NSLocalizedString("Too Many Participants: a share cannot be saved because too many participants are attached to the share.", comment: "Known iCloud Error") + case .unknownItem: + return NSLocalizedString("Unknown Item: the specified record does not exist.", comment: "Known iCloud Error") + case .userDeletedZone: + return NSLocalizedString("User Deleted Zone: the user has deleted this zone from the settings UI.", comment: "Known iCloud Error") + case .zoneBusy: + return NSLocalizedString("Zone Busy: the server is too busy to handle the zone operation.", comment: "Known iCloud Error") + case .zoneNotFound: + return NSLocalizedString("Zone Not Found: the specified record zone does not exist on the server.", comment: "Known iCloud Error") + default: + return NSLocalizedString("Unhandled Error.", comment: "Unknown iCloud Error") + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 1b3d49b94..0d9072bae 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -23,46 +23,17 @@ final class CloudKitAccountZone: CloudKitZone { self.database = container.privateCloudDatabase } - + /// Persist a feed record to iCloud and return the external key + func createFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { + let record = CKRecord(recordType: "Feed", recordID: generateRecordID()) + record["url"] = url + if let editedName = editedName { + record["editedName"] = editedName + } + + save(record: record, completion: completion) + } -// func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?) { -// let changesOperation = CKFetchDatabaseChangesOperation(previousServerChangeToken: databaseChangeToken) -// -// /// Only update the changeToken when fetch process completes -// changesOperation.changeTokenUpdatedBlock = { [weak self] newToken in -// self?.databaseChangeToken = newToken -// } -// -// changesOperation.fetchDatabaseChangesCompletionBlock = { -// [weak self] -// newToken, _, error in -// guard let self = self else { return } -// switch CloudKitErrorHandler.shared.resultType(with: error) { -// case .success: -// self.databaseChangeToken = newToken -// // Fetch the changes in zone level -// self.fetchChangesInZones(callback) -// case .retry(let timeToWait, _): -// CloudKitErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { -// self.fetchChangesInDatabase(callback) -// }) -// case .recoverableError(let reason, _): -// switch reason { -// case .changeTokenExpired: -// /// The previousServerChangeToken value is too old and the client must re-sync from scratch -// self.databaseChangeToken = nil -// self.fetchChangesInDatabase(callback) -// default: -// return -// } -// default: -// return -// } -// } -// -// database.add(changesOperation) -// } - // private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { // let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) // changesOp.fetchAllChanges = true diff --git a/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift b/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift deleted file mode 100644 index 5a69c9ce7..000000000 --- a/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// CloudKitErrorHandler.swift -// Account -// -// Created by @randycarney on 12/12/17. -// Derived from https://github.com/caiyue1993/IceCream -// - -import Foundation -import CloudKit - -/// This struct helps you handle all the CKErrors and has been updated to the current Apple documentation(12/15/2017): -/// https://developer.apple.com/documentation/cloudkit/ckerror.code - -struct CloudKitErrorHandler { - - static let shared = CloudKitErrorHandler() - - /// We could classify all the results that CKOperation returns into the following five CKOperationResultTypes - enum CloudKitOperationResultType { - case success - case retry(afterSeconds: Double, message: String) - case chunk - case recoverableError(reason: CloudKitOperationFailReason, message: String) - case fail(reason: CloudKitOperationFailReason, message: String) - } - - /// The reason of CloudKit failure could be classified into following 8 cases - enum CloudKitOperationFailReason { - case changeTokenExpired - case network - case quotaExceeded - case partialFailure - case serverRecordChanged - case shareRelated - case unhandledErrorCode - case unknown - } - - func resultType(with error: Error?) -> CloudKitOperationResultType { - 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) - - } - - } - - func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { - let delayTime = DispatchTime.now() + retryAfter - 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)" - } - -} diff --git a/Frameworks/Account/CloudKit/CloudKitResult.swift b/Frameworks/Account/CloudKit/CloudKitResult.swift new file mode 100644 index 000000000..7f6592f63 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitResult.swift @@ -0,0 +1,53 @@ +// +// CloudKitResult.swift +// Account +// +// Created by Maurice Parker on 3/26/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +enum CloudKitResult { + case success + case retry(afterSeconds: Double) + case chunk + case changeTokenExpired + case partialFailure + case serverRecordChanged + case noZone + case failure(error: Error) + + static func resolve(_ error: Error?) -> CloudKitResult { + + guard error != nil else { return .success } + + guard let ckError = error as? CKError else { + return .failure(error: error!) + } + + switch ckError.code { + case .serviceUnavailable, .requestRateLimited, .zoneBusy: + if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? Double { + return .retry(afterSeconds: retry) + } else { + return .failure(error: error!) + } + case .changeTokenExpired: + return .changeTokenExpired + case .serverRecordChanged: + return .serverRecordChanged + case .partialFailure: + return .partialFailure + case .limitExceeded: + return .chunk + case .zoneNotFound, .userDeletedZone: + return .noZone + default: + return .failure(error: error!) + } + + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 52acfbc96..44a686f69 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -8,13 +8,16 @@ import CloudKit +public enum CloudKitZoneError: Error { + case unknown +} + public protocol CloudKitZone: class { + static var zoneID: CKRecordZone.ID { get } + var container: CKContainer { get } var database: CKDatabase { get } - static var zoneID: CKRecordZone.ID { get } - - func startUp(completion: @escaping (Result) -> Void) // func prepare() @@ -33,12 +36,44 @@ public protocol CloudKitZone: class { extension CloudKitZone { - func startUp(completion: @escaping (Result) -> Void) { + var changeTokenKey: String { + return "cloudkit.server.token.\(Self.zoneID.zoneName)" + } + + var changeToken: CKServerChangeToken? { + get { + guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) + } + set { + guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else { + UserDefaults.standard.removeObject(forKey: changeTokenKey) + return + } + UserDefaults.standard.set(data, forKey: changeTokenKey) + } + } + + var zoneConfiguration: CKFetchRecordZoneChangesOperation.ZoneConfiguration { + let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + config.previousServerChangeToken = changeToken + return config + } + + func generateRecordID() -> CKRecord.ID { + return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) + } + + func createZoneRecord(completion: @escaping (Result) -> Void) { database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in if let error = error { - completion(.failure(error)) + DispatchQueue.main.async { + completion(.failure(error)) + } } else { - completion(.success(())) + DispatchQueue.main.async { + completion(.success(())) + } } } } @@ -78,37 +113,62 @@ extension CloudKitZone { // }) // } + public func save(record: CKRecord, completion: @escaping (Result) -> Void) { + database.save(record) {(savedRecord, error) in + + switch CloudKitResult.resolve(error) { + + case .success: + DispatchQueue.main.async { + if let savedRecord = savedRecord { + completion(.success(savedRecord.recordID.recordName)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + } + + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { + self.save(record: record, completion: completion) + } + + default: + return + } + + } + + } + /// Sync local data to CloudKit /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy - public func syncRecordsToCloudKit(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: ((Error?) -> ())? = nil) { - let modifyOpe = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete) + public func syncRecordsToCloudKit(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { + let op = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete) let config = CKOperation.Configuration() config.isLongLived = true - modifyOpe.configuration = config + op.configuration = config // We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first // Apple suggests using .ifServerRecordUnchanged save policy // For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/) - modifyOpe.savePolicy = .changedKeys + op.savePolicy = .changedKeys // To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail) // If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate - modifyOpe.isAtomic = true + op.isAtomic = true - modifyOpe.modifyRecordsCompletionBlock = { - [weak self] - (_, _, error) in + op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in guard let self = self else { return } - switch CloudKitErrorHandler.shared.resultType(with: error) { + switch CloudKitResult.resolve(error) { case .success: DispatchQueue.main.async { - completion?(nil) + completion(.success(())) } - case .retry(let timeToWait, _): - CloudKitErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait) { + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .chunk: @@ -123,7 +183,14 @@ extension CloudKitZone { } } - database.add(modifyOpe) + database.add(op) + } + + func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + retryAfter + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) } } diff --git a/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift b/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift index f8a176678..8c6fe43c8 100644 --- a/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift +++ b/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift @@ -13,14 +13,13 @@ extension WebFeed: CloudKitRecordConvertible { enum CloudKitKey: String { case url - case homePageURL case editedName } static var cloudKitZoneID: CKRecordZone.ID { return CloudKitAccountZone.zoneID } - + var cloudKitPrimaryKey: String { return externalID! } @@ -28,7 +27,6 @@ extension WebFeed: CloudKitRecordConvertible { var cloudKitRecord: CKRecord { let record = CKRecord(recordType: Self.cloudKitRecordType) record[.url] = url - record[.homePageURL] = homePageURL record[.editedName] = editedName return record }