From 3b31f2562d724bf66b6d4595f6fdbf6a788713f1 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 11:53:52 -0500 Subject: [PATCH] Stub out fetching feed changes. --- .../Account/Account.xcodeproj/project.pbxproj | 8 + .../CloudKit/CKRecord+Extensions.swift | 18 ++ .../CloudKit/CloudKitAccountDelegate.swift | 3 +- .../CloudKit/CloudKitAccountZone.swift | 60 +------ .../CloudKitAccountZoneDelegate.swift | 46 +++++ .../Account/CloudKit/CloudKitZone.swift | 162 +++++++++++------- 6 files changed, 178 insertions(+), 119 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CKRecord+Extensions.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index b57f1def1..fbc41bc77 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; }; 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; }; + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */; }; + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */; }; 513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; }; 5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; }; 5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; }; @@ -264,6 +266,8 @@ 510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = ""; }; 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = ""; }; 511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = ""; }; + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+Extensions.swift"; sourceTree = ""; }; + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZoneDelegate.swift; sourceTree = ""; }; 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = ""; }; 513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = ""; }; 5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = ""; }; @@ -512,8 +516,10 @@ isa = PBXGroup; children = ( 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1076,6 +1082,7 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, @@ -1174,6 +1181,7 @@ 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */, 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CKRecord+Extensions.swift b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift new file mode 100644 index 000000000..bb1696a89 --- /dev/null +++ b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift @@ -0,0 +1,18 @@ +// +// CKRecord+Extensions.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension CKRecord.ID { + + var externalID: String { + return recordName + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index a031b820f..815b14c89 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -132,7 +132,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.accountZone.createFeed(url: urlString, editedName: name) { result in + self.accountZone.createWebFeed(url: urlString, editedName: name) { result in switch result { case .success(let externalID): @@ -231,6 +231,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { + accountZone.delegate = CloudKitAcountZoneDelegate(account: account) } func accountWillBeDeleted(_ account: Account) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index fd326f5a4..670b2c556 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -17,6 +17,7 @@ final class CloudKitAccountZone: CloudKitZone { let container: CKContainer let database: CKDatabase + var delegate: CloudKitZoneDelegate? = nil struct CloudKitWebFeed { static let recordType = "WebFeed" @@ -32,7 +33,7 @@ final class CloudKitAccountZone: CloudKitZone { } /// Persist a web feed record to iCloud and return the external key - func createFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { + func createWebFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) record[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { @@ -42,7 +43,7 @@ final class CloudKitAccountZone: CloudKitZone { save(record: record) { result in switch result { case .success: - completion(.success(record.recordID.recordName)) + completion(.success(record.recordID.externalID)) case .failure(let error): completion(.failure(error)) } @@ -56,60 +57,5 @@ final class CloudKitAccountZone: CloudKitZone { } delete(externalID: externalID, completion: completion) } -// private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { -// let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) -// changesOp.fetchAllChanges = true -// -// changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// } -// -// changesOp.recordChangedBlock = { [weak self] record in -// /// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here. -// /// Handle the record: -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return } -// syncObject.add(record: record) -// } -// -// changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == recordId.zoneID }) else { return } -// syncObject.delete(recordID: recordId) -// } -// -// changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in -// guard let self = self else { return } -// switch ErrorHandler.shared.resultType(with: error) { -// case .success: -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// case .retry(let timeToWait, _): -// ErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { -// self.fetchChangesInZones(callback) -// }) -// case .recoverableError(let reason, _): -// switch reason { -// case .changeTokenExpired: -// /// The previousServerChangeToken value is too old and the client must re-sync from scratch -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = nil -// self.fetchChangesInZones(callback) -// default: -// return -// } -// default: -// return -// } -// } -// -// changesOp.fetchRecordZoneChangesCompletionBlock = { error in -// callback?(error) -// } -// -// database.add(changesOp) -// } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift new file mode 100644 index 000000000..f2194820c --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -0,0 +1,46 @@ +// +// CloudKitAccountZoneDelegate.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + + weak var account: Account? + + init(account: Account) { + self.account = account + } + + func cloudKitDidChange(record: CKRecord) { + switch record.recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + addWebFeed(record) + default: + assertionFailure("Unknown record type: \(record.recordType)") + } + } + + func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { + switch recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + removeWebFeed(recordID.externalID) + default: + assertionFailure("Unknown record type: \(recordID.externalID)") + } + } + + func addWebFeed(_ record: CKRecord) { + + } + + func removeWebFeed(_ externalID: String) { + + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 57137a8b9..75be66fa1 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -8,18 +8,24 @@ import CloudKit -public enum CloudKitZoneError: Error { +enum CloudKitZoneError: Error { case userDeletedZone case invalidParameter case unknown } -public protocol CloudKitZone: class { +protocol CloudKitZoneDelegate: class { + func cloudKitDidChange(record: CKRecord); + func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) +} + +protocol CloudKitZone: class { static var zoneID: CKRecordZone.ID { get } var container: CKContainer { get } var database: CKDatabase { get } + var delegate: CloudKitZoneDelegate? { get set } // func prepare() @@ -38,57 +44,10 @@ public protocol CloudKitZone: class { extension CloudKitZone { - 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 { - DispatchQueue.main.async { - completion(.failure(error)) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - // func prepare() { - // syncObjects.forEach { - // $0.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in - // guard let self = self else { return } - // self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete) - // } - // } - // } - func resumeLongLivedOperationIfPossible() { container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in guard let self = self, error == nil, let ids = opeIDs else { return } @@ -96,9 +55,6 @@ extension CloudKitZone { self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in guard let self = self, error == nil else { return } if let modifyOp = ope as? CKModifyRecordsOperation { - modifyOp.modifyRecordsCompletionBlock = { (_,_,_) in - print("Resume modify records success!") - } self.container.add(modifyOp) } }) @@ -115,18 +71,16 @@ extension CloudKitZone { // }) // } - public func save(record: CKRecord, completion: @escaping (Result) -> Void) { + func save(record: CKRecord, completion: @escaping (Result) -> Void) { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } - public func delete(externalID: String, completion: @escaping (Result) -> Void) { + func delete(externalID: String, completion: @escaping (Result) -> Void) { let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } - /// Sync local data to CloudKit - /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy - public func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { + func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) let config = CKOperation.Configuration() @@ -169,8 +123,6 @@ extension CloudKitZone { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: - /// CloudKit says maximum number of items in a single request is 400. - /// So I think 300 should be fine by them. let chunkedRecords = recordsToSave.chunked(into: 300) for chunk in chunkedRecords { self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) @@ -185,12 +137,100 @@ extension CloudKitZone { database.add(op) } + func fetchChangesInZones(completion: @escaping (Result) -> Void) { + let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + zoneConfig.previousServerChangeToken = changeToken + let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) + op.fetchAllChanges = true + + op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in + guard let self = self else { return } + self.changeToken = token + } + + op.recordChangedBlock = { [weak self] record in + guard let self = self else { return } + self.delegate?.cloudKitDidChange(record: record) + } + + op.recordWithIDWasDeletedBlock = { [weak self] recordId, recordType in + guard let self = self else { return } + self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId) + } + + op.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + self.changeToken = token + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { + self.fetchChangesInZones(completion: completion) + } + default: + return + } + } + + op.fetchRecordZoneChangesCompletionBlock = { error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + + database.add(op) + } + +} + +private extension CloudKitZone { + + 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 createZoneRecord(completion: @escaping (Result) -> Void) { + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(error)) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { let delayTime = DispatchTime.now() + retryAfter DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { block() }) } - -} +}