More infrastructure work on CloudKit.

This commit is contained in:
Maurice Parker 2020-03-27 13:59:42 -05:00
parent 53b00c5414
commit f288e3d5d8
7 changed files with 245 additions and 259 deletions

View File

@ -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 = "<group>"; };
51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = "<group>"; };
51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = "<group>"; };
51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = "<group>"; };
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = "<group>"; };
@ -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 = "<group>"; };
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorHandler.swift; sourceTree = "<group>"; };
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = "<group>"; };
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = "<group>"; };
51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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")
}
}
}

View File

@ -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<String, Error>) -> 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

View File

@ -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)"
}
}

View File

@ -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!)
}
}
}

View File

@ -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, Error>) -> Void)
// func prepare()
@ -33,12 +36,44 @@ public protocol CloudKitZone: class {
extension CloudKitZone {
func startUp(completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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<String, Error>) -> 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, Error>) -> 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()
})
}
}

View File

@ -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
}