From 3a228be14296678ab4a1f5245a1ea807d9f0ce36 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 15:04:38 -0500 Subject: [PATCH] Add user web feed subscription management. --- .../Account/Account.xcodeproj/project.pbxproj | 8 +- ...ner.swift => CKContainer+Extensions.swift} | 14 ++-- .../CloudKit/CloudKitAccountDelegate.swift | 4 +- .../CloudKit/CloudKitAccountZone.swift | 6 +- .../Account/CloudKit/CloudKitPublicZone.swift | 81 ++++++++++++------- .../Account/CloudKit/CloudKitZone.swift | 38 +++++++-- 6 files changed, 102 insertions(+), 49 deletions(-) rename Frameworks/Account/CloudKit/{CloudKitContainer.swift => CKContainer+Extensions.swift} (71%) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index c030be8d3..e958c324f 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -60,7 +60,7 @@ 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; - 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CloudKitContainer.swift */; }; + 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CKContainer+Extensions.swift */; }; 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 */; }; @@ -298,7 +298,7 @@ 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; - 51B544662438F410003F03BF /* CloudKitContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitContainer.swift; sourceTree = ""; }; + 51B544662438F410003F03BF /* CKContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKContainer+Extensions.swift"; sourceTree = ""; }; 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 = ""; }; @@ -525,13 +525,13 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( + 51B544662438F410003F03BF /* CKContainer+Extensions.swift */, 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, - 51B544662438F410003F03BF /* CloudKitContainer.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, @@ -1159,7 +1159,7 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, - 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */, + 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitContainer.swift b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift similarity index 71% rename from Frameworks/Account/CloudKit/CloudKitContainer.swift rename to Frameworks/Account/CloudKit/CKContainer+Extensions.swift index a50390665..884b4b3ad 100644 --- a/Frameworks/Account/CloudKit/CloudKitContainer.swift +++ b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift @@ -1,5 +1,5 @@ // -// CloudKitContainer.swift +// CKContainer+Extensions.swift // Account // // Created by Maurice Parker on 4/4/20. @@ -9,11 +9,11 @@ import Foundation import CloudKit -struct CloudKitContainer { +extension CKContainer { private static let userRecordIDKey = "cloudkit.server.userRecordID" - static var userRecordID: String? { + var userRecordID: String? { get { return UserDefaults.standard.string(forKey: Self.userRecordIDKey) } @@ -26,13 +26,13 @@ struct CloudKitContainer { } } - static func fetchUserRecordID() { - guard Self.userRecordID == nil else { return } - CKContainer.default().fetchUserRecordID { recordID, error in + func fetchUserRecordID() { + guard userRecordID == nil else { return } + fetchUserRecordID { recordID, error in guard let recordID = recordID, error == nil else { return } - Self.userRecordID = recordID.recordName + self.userRecordID = recordID.recordName } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index ec6456f7f..044dd6093 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -219,7 +219,7 @@ final class CloudKitAccountDelegate: AccountDelegate { self.publicZone.createSubscription(feed) { result in self.refreshProgress.completeTask() if case .failure(let error) = result { - os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + os_log(.error, log: self.log, "An error occurred while creating the subscription: %@.", error.localizedDescription) } } @@ -438,7 +438,7 @@ final class CloudKitAccountDelegate: AccountDelegate { // Check to see if this is a new account and initialize anything we need if account.externalID == nil { - CloudKitContainer.fetchUserRecordID() + container.fetchUserRecordID() accountZone.findOrCreateAccount() { result in switch result { case .success(let externalID): diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index b688540c8..d46e1e8ea 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -25,7 +25,7 @@ final class CloudKitAccountZone: CloudKitZone { var delegate: CloudKitZoneDelegate? struct CloudKitWebFeed { - static let recordType = "WebFeed" + static let recordType = "AccountWebFeed" struct Fields { static let url = "url" static let editedName = "editedName" @@ -34,7 +34,7 @@ final class CloudKitAccountZone: CloudKitZone { } struct CloudKitContainer { - static let recordType = "Container" + static let recordType = "AccountContainer" struct Fields { static let isAccount = "isAccount" static let name = "name" @@ -77,7 +77,7 @@ final class CloudKitAccountZone: CloudKitZone { } } - modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + save(records, completion: completion) } /// Persist a web feed record to iCloud and return the external key diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 7892d2146..7ede9b48e 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -31,7 +31,7 @@ final class CloudKitPublicZone: CloudKitZone { } } - struct CloudKitUserWebFeedCheck { + struct CloudKitWebFeedCheck { static let recordType = "WebFeedCheck" struct Fields { static let webFeed = "webFeed" @@ -59,55 +59,79 @@ final class CloudKitPublicZone: CloudKitZone { completion() } + /// Create a CloudKit subscription for the webfeed and any other supporting records that we need func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) - let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) - save(webFeedRecord) { result in + func createSubscription(_ webFeedRecordRef: CKRecord.Reference) { + let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) + let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] + subscription.notificationInfo = info + + self.save(subscription) { result in + switch result { + case .success(let subscription): + + let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) + userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = self.container?.userRecordID + userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef + userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + + self.save(userSubscriptionRecord, completion: completion) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + fetch(externalID: webFeed.url.md5String) { result in switch result { - case .success: + case .success(let record): + let webFeedRecordRef = CKRecord.Reference(record: record, action: .none) + createSubscription(webFeedRecordRef) + + case .failure: + + let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) - let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) - - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] - subscription.notificationInfo = info - - self.save(subscription) { result in - switch result { - case .success(let subscription): - - let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) - userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = CloudKitContainer.userRecordID - userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef - userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) + webFeedRecord[CloudKitWebFeed.Fields.url] = webFeed.url + webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" + webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" - self.save(userSubscriptionRecord, completion: completion) - + let webFeedCheckRecord = CKRecord(recordType: CloudKitWebFeedCheck.recordType, recordID: self.generateRecordID()) + webFeedRecord[CloudKitWebFeedCheck.Fields.webFeed] = webFeedRecordRef + webFeedRecord[CloudKitWebFeedCheck.Fields.lastCheck] = Date.distantPast + + self.save([webFeedRecord, webFeedCheckRecord]) { result in + switch result { + case .success: + createSubscription(webFeedRecordRef) case .failure(let error): completion(.failure(error)) } } - case .failure(let error): - completion(.failure(error)) } } + } /// Remove the subscription for the given feed along with its supporting record func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - guard let userRecordID = CloudKitContainer.userRecordID else { + guard let userRecordID = self.container?.userRecordID else { completion(.failure(CloudKitZoneError.invalidParameter)) return } let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let predicate = NSPredicate(format: "user = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) + let predicate = NSPredicate(format: "userRecordID = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) let ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate) query(ckQuery) { result in @@ -125,7 +149,8 @@ final class CloudKitPublicZone: CloudKitZone { } } else { - completion(.failure(CloudKitZoneError.unknown)) + os_log(.error, log: self.log, "Remove subscription error. The subscription wasn't found.") + completion(.success(())) } case .failure(let error): diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index a461dd370..4a47d2baf 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -10,10 +10,14 @@ import CloudKit import os.log import RSWeb -enum CloudKitZoneError: Error { +enum CloudKitZoneError: LocalizedError { case userDeletedZone case invalidParameter case unknown + + var errorDescription: String? { + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } } protocol CloudKitZoneDelegate: class { @@ -191,18 +195,38 @@ extension CloudKitZone { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } + /// Save the CKRecords + func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + } + /// Save the CKSubscription func save(_ subscription: CKSubscription, completion: @escaping (Result) -> Void) { database?.save(subscription) { savedSubscription, error in switch CloudKitZoneResult.resolve(error) { case .success: - completion(.success((savedSubscription!))) + DispatchQueue.main.async { + completion(.success((savedSubscription!))) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.save(subscription, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self.retryIfPossible(after: timeToWait) { self.save(subscription, completion: completion) } default: - completion(.failure(CloudKitError(error!))) + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } } } } @@ -228,13 +252,17 @@ extension CloudKitZone { database?.delete(withSubscriptionID: subscriptionID) { _, error in switch CloudKitZoneResult.resolve(error) { case .success: - completion(.success(())) + DispatchQueue.main.async { + completion(.success(())) + } case .retry(let timeToWait): self.retryIfPossible(after: timeToWait) { self.delete(subscriptionID: subscriptionID, completion: completion) } default: - completion(.failure(CloudKitError(error!))) + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } } } }