From 71b5c8bc863c3e50e76fa100ac64ee6b1baa85ae Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 13:33:49 -0500 Subject: [PATCH] Add user feed subscription management. --- .../Account/Account.xcodeproj/project.pbxproj | 4 + .../CloudKit/CloudKitAccountDelegate.swift | 4 +- .../Account/CloudKit/CloudKitContainer.swift | 39 +++++++++ .../Account/CloudKit/CloudKitPublicZone.swift | 84 ++++++++++++++++++- .../Account/CloudKit/CloudKitZone.swift | 56 +++++++++---- 5 files changed, 168 insertions(+), 19 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitContainer.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index e8c367aeb..c030be8d3 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -60,6 +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 */; }; 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 */; }; @@ -297,6 +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 = ""; }; 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 = ""; }; @@ -529,6 +531,7 @@ 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, + 51B544662438F410003F03BF /* CloudKitContainer.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, @@ -1156,6 +1159,7 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, + 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 46da00c64..ec6456f7f 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -436,7 +436,9 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) + // Check to see if this is a new account and initialize anything we need if account.externalID == nil { + CloudKitContainer.fetchUserRecordID() accountZone.findOrCreateAccount() { result in switch result { case .success(let externalID): @@ -451,7 +453,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } } zones.forEach { zone in - zone.subscribe() + zone.subscribeToZoneChanges() } } diff --git a/Frameworks/Account/CloudKit/CloudKitContainer.swift b/Frameworks/Account/CloudKit/CloudKitContainer.swift new file mode 100644 index 000000000..a50390665 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitContainer.swift @@ -0,0 +1,39 @@ +// +// CloudKitContainer.swift +// Account +// +// Created by Maurice Parker on 4/4/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +struct CloudKitContainer { + + private static let userRecordIDKey = "cloudkit.server.userRecordID" + + static var userRecordID: String? { + get { + return UserDefaults.standard.string(forKey: Self.userRecordIDKey) + } + set { + guard let userRecordID = newValue else { + UserDefaults.standard.removeObject(forKey: Self.userRecordIDKey) + return + } + UserDefaults.standard.set(userRecordID, forKey: Self.userRecordIDKey) + } + } + + static func fetchUserRecordID() { + guard Self.userRecordID == nil else { return } + CKContainer.default().fetchUserRecordID { recordID, error in + guard let recordID = recordID, error == nil else { + return + } + Self.userRecordID = recordID.recordName + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 0cfe3eee1..7892d2146 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -31,10 +31,18 @@ final class CloudKitPublicZone: CloudKitZone { } } + struct CloudKitUserWebFeedCheck { + static let recordType = "WebFeedCheck" + struct Fields { + static let webFeed = "webFeed" + static let lastCheck = "lastCheck" + } + } + struct CloudKitUserSubscription { static let recordType = "UserSubscription" struct Fields { - static let user = "user" + static let userRecordID = "userRecordID" static let webFeed = "webFeed" static let subscriptionID = "subscriptionID" } @@ -45,18 +53,86 @@ final class CloudKitPublicZone: CloudKitZone { self.database = container.publicCloudDatabase } - func subscribe() {} + func subscribeToZoneChanges() {} func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { completion() } func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - completion(.success(())) + let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) + + save(webFeedRecord) { result in + switch result { + case .success: + + 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 + + self.save(userSubscriptionRecord, completion: completion) + + 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) { - completion(.success(())) + guard let userRecordID = CloudKitContainer.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 ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate) + + query(ckQuery) { result in + switch result { + case .success(let records): + + if records.count > 0, let subscriptionID = records[0][CloudKitUserSubscription.Fields.subscriptionID] as? String { + self.delete(subscriptionID: subscriptionID) { result in + switch result { + case .success: + self.delete(recordID: records[0].recordID, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 20ba10960..a461dd370 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -41,7 +41,7 @@ protocol CloudKitZone: class { func generateRecordID() -> CKRecord.ID /// Subscribe to changes at a zone level - func subscribe() + func subscribeToZoneChanges() /// Process a remove notification func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) @@ -59,27 +59,18 @@ extension CloudKitZone { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func subscribe() { - + func subscribeToZoneChanges() { let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) let info = CKSubscription.NotificationInfo() info.shouldSendContentAvailable = true subscription.notificationInfo = info - database?.save(subscription) { _, error in - switch CloudKitZoneResult.resolve(error) { - case .success: - break - case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.subscribe() - } - default: - os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", Self.zoneID.zoneName, error?.localizedDescription ?? "Unknown") + save(subscription) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@.", Self.zoneID.zoneName, error.localizedDescription) } } - } func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { @@ -200,6 +191,27 @@ extension CloudKitZone { modify(recordsToSave: [record], 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!))) + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.save(subscription, completion: completion) + } + default: + completion(.failure(CloudKitError(error!))) + } + } + } + + /// Delete a CKRecord using its recordID + func delete(recordID: CKRecord.ID, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + /// Delete a CKRecord using its externalID func delete(externalID: String?, completion: @escaping (Result) -> Void) { guard let externalID = externalID else { @@ -211,6 +223,22 @@ extension CloudKitZone { modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } + /// Delete a CKSubscription + func delete(subscriptionID: String, completion: @escaping (Result) -> Void) { + database?.delete(withSubscriptionID: subscriptionID) { _, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + completion(.success(())) + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.delete(subscriptionID: subscriptionID, completion: completion) + } + default: + completion(.failure(CloudKitError(error!))) + } + } + } + /// Modify and delete the supplied CKRecords and CKRecord.IDs func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)