Add user web feed subscription management.

This commit is contained in:
Maurice Parker 2020-04-04 15:04:38 -05:00
parent 231e3a12e2
commit 3a228be142
6 changed files with 102 additions and 49 deletions

View File

@ -60,7 +60,7 @@
519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; };
519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; };
519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.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 */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; };
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; };
51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.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 = "<group>"; }; 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = "<group>"; };
519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = "<group>"; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = "<group>"; };
519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = "<group>"; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = "<group>"; };
51B544662438F410003F03BF /* CloudKitContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitContainer.swift; sourceTree = "<group>"; }; 51B544662438F410003F03BF /* CKContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKContainer+Extensions.swift"; sourceTree = "<group>"; };
51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = "<group>"; };
@ -525,13 +525,13 @@
5103A9D7242253DC00410853 /* CloudKit */ = { 5103A9D7242253DC00410853 /* CloudKit */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
51B544662438F410003F03BF /* CKContainer+Extensions.swift */,
512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */,
5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */,
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */,
512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */,
519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */,
519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */,
51B544662438F410003F03BF /* CloudKitContainer.swift */,
5150FFFD243823B800C1A442 /* CloudKitError.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */,
5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */,
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */,
@ -1159,7 +1159,7 @@
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */,
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */, 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */,
9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */,
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */,
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */,

View File

@ -1,5 +1,5 @@
// //
// CloudKitContainer.swift // CKContainer+Extensions.swift
// Account // Account
// //
// Created by Maurice Parker on 4/4/20. // Created by Maurice Parker on 4/4/20.
@ -9,11 +9,11 @@
import Foundation import Foundation
import CloudKit import CloudKit
struct CloudKitContainer { extension CKContainer {
private static let userRecordIDKey = "cloudkit.server.userRecordID" private static let userRecordIDKey = "cloudkit.server.userRecordID"
static var userRecordID: String? { var userRecordID: String? {
get { get {
return UserDefaults.standard.string(forKey: Self.userRecordIDKey) return UserDefaults.standard.string(forKey: Self.userRecordIDKey)
} }
@ -26,13 +26,13 @@ struct CloudKitContainer {
} }
} }
static func fetchUserRecordID() { func fetchUserRecordID() {
guard Self.userRecordID == nil else { return } guard userRecordID == nil else { return }
CKContainer.default().fetchUserRecordID { recordID, error in fetchUserRecordID { recordID, error in
guard let recordID = recordID, error == nil else { guard let recordID = recordID, error == nil else {
return return
} }
Self.userRecordID = recordID.recordName self.userRecordID = recordID.recordName
} }
} }

View File

@ -219,7 +219,7 @@ final class CloudKitAccountDelegate: AccountDelegate {
self.publicZone.createSubscription(feed) { result in self.publicZone.createSubscription(feed) { result in
self.refreshProgress.completeTask() self.refreshProgress.completeTask()
if case .failure(let error) = result { 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 // Check to see if this is a new account and initialize anything we need
if account.externalID == nil { if account.externalID == nil {
CloudKitContainer.fetchUserRecordID() container.fetchUserRecordID()
accountZone.findOrCreateAccount() { result in accountZone.findOrCreateAccount() { result in
switch result { switch result {
case .success(let externalID): case .success(let externalID):

View File

@ -25,7 +25,7 @@ final class CloudKitAccountZone: CloudKitZone {
var delegate: CloudKitZoneDelegate? var delegate: CloudKitZoneDelegate?
struct CloudKitWebFeed { struct CloudKitWebFeed {
static let recordType = "WebFeed" static let recordType = "AccountWebFeed"
struct Fields { struct Fields {
static let url = "url" static let url = "url"
static let editedName = "editedName" static let editedName = "editedName"
@ -34,7 +34,7 @@ final class CloudKitAccountZone: CloudKitZone {
} }
struct CloudKitContainer { struct CloudKitContainer {
static let recordType = "Container" static let recordType = "AccountContainer"
struct Fields { struct Fields {
static let isAccount = "isAccount" static let isAccount = "isAccount"
static let name = "name" 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 /// Persist a web feed record to iCloud and return the external key

View File

@ -31,7 +31,7 @@ final class CloudKitPublicZone: CloudKitZone {
} }
} }
struct CloudKitUserWebFeedCheck { struct CloudKitWebFeedCheck {
static let recordType = "WebFeedCheck" static let recordType = "WebFeedCheck"
struct Fields { struct Fields {
static let webFeed = "webFeed" static let webFeed = "webFeed"
@ -59,55 +59,79 @@ final class CloudKitPublicZone: CloudKitZone {
completion() completion()
} }
/// Create a CloudKit subscription for the webfeed and any other supporting records that we need
func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result<Void, Error>) -> Void) { func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result<Void, Error>) -> 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 { 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 webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none)
let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID)
let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) webFeedRecord[CloudKitWebFeed.Fields.url] = webFeed.url
webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = ""
let info = CKSubscription.NotificationInfo() webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = ""
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) 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): case .failure(let error):
completion(.failure(error)) completion(.failure(error))
} }
} }
case .failure(let error):
completion(.failure(error))
} }
} }
} }
/// Remove the subscription for the given feed along with its supporting record /// Remove the subscription for the given feed along with its supporting record
func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result<Void, Error>) -> Void) { func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result<Void, Error>) -> Void) {
guard let userRecordID = CloudKitContainer.userRecordID else { guard let userRecordID = self.container?.userRecordID else {
completion(.failure(CloudKitZoneError.invalidParameter)) completion(.failure(CloudKitZoneError.invalidParameter))
return return
} }
let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID)
let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) 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) let ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate)
query(ckQuery) { result in query(ckQuery) { result in
@ -125,7 +149,8 @@ final class CloudKitPublicZone: CloudKitZone {
} }
} else { } 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): case .failure(let error):

View File

@ -10,10 +10,14 @@ import CloudKit
import os.log import os.log
import RSWeb import RSWeb
enum CloudKitZoneError: Error { enum CloudKitZoneError: LocalizedError {
case userDeletedZone case userDeletedZone
case invalidParameter case invalidParameter
case unknown case unknown
var errorDescription: String? {
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
}
} }
protocol CloudKitZoneDelegate: class { protocol CloudKitZoneDelegate: class {
@ -191,18 +195,38 @@ extension CloudKitZone {
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
} }
/// Save the CKRecords
func save(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
modify(recordsToSave: records, recordIDsToDelete: [], completion: completion)
}
/// Save the CKSubscription /// Save the CKSubscription
func save(_ subscription: CKSubscription, completion: @escaping (Result<CKSubscription, Error>) -> Void) { func save(_ subscription: CKSubscription, completion: @escaping (Result<CKSubscription, Error>) -> Void) {
database?.save(subscription) { savedSubscription, error in database?.save(subscription) { savedSubscription, error in
switch CloudKitZoneResult.resolve(error) { switch CloudKitZoneResult.resolve(error) {
case .success: 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): case .retry(let timeToWait):
self.retryIfPossible(after: timeToWait) { self.retryIfPossible(after: timeToWait) {
self.save(subscription, completion: completion) self.save(subscription, completion: completion)
} }
default: default:
completion(.failure(CloudKitError(error!))) DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
} }
} }
} }
@ -228,13 +252,17 @@ extension CloudKitZone {
database?.delete(withSubscriptionID: subscriptionID) { _, error in database?.delete(withSubscriptionID: subscriptionID) { _, error in
switch CloudKitZoneResult.resolve(error) { switch CloudKitZoneResult.resolve(error) {
case .success: case .success:
completion(.success(())) DispatchQueue.main.async {
completion(.success(()))
}
case .retry(let timeToWait): case .retry(let timeToWait):
self.retryIfPossible(after: timeToWait) { self.retryIfPossible(after: timeToWait) {
self.delete(subscriptionID: subscriptionID, completion: completion) self.delete(subscriptionID: subscriptionID, completion: completion)
} }
default: default:
completion(.failure(CloudKitError(error!))) DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
} }
} }
} }