From 6364539608e594750c1805b5707447b61ac8f4e3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 6 Apr 2020 02:15:28 -0500 Subject: [PATCH] Handle edge case where the user deletes the iCloud data. --- .../CloudKit/CloudKitAccountDelegate.swift | 26 +++- .../CloudKit/CloudKitAccountZone.swift | 34 +++++ .../CloudKit/CloudKitArticlesZone.swift | 48 ++++++- .../Account/CloudKit/CloudKitZone.swift | 121 ++++++++++++------ 4 files changed, 186 insertions(+), 43 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index f01b94c93..efd921396 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -103,6 +103,7 @@ final class CloudKitAccountDelegate: AccountDelegate { completion(.success(())) case .failure(let error): self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + self.processAccountError(account, error) completion(.failure(error)) } } @@ -133,7 +134,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Refreshing article statuses...") - articlesZone.fetchChangesInZone() { result in + articlesZone.refreshArticleStatus() { result in os_log(.debug, log: self.log, "Done refreshing article statuses.") switch result { case .success: @@ -284,6 +285,7 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.editedName = name completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -298,6 +300,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.removeWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -313,6 +316,7 @@ final class CloudKitAccountDelegate: AccountDelegate { toContainer.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -327,6 +331,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -342,6 +347,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -360,6 +366,7 @@ final class CloudKitAccountDelegate: AccountDelegate { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) } case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -374,6 +381,7 @@ final class CloudKitAccountDelegate: AccountDelegate { folder.name = name completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -388,6 +396,7 @@ final class CloudKitAccountDelegate: AccountDelegate { account.removeFolder(folder) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -434,6 +443,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -548,21 +558,35 @@ private extension CloudKitAccountDelegate { } case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() completion(.failure(error)) } } case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() completion(.failure(error)) } } case .failure(let error): + self.processAccountError(account, error) self.refreshProgress.clear() completion(.failure(error)) } } } + func processAccountError(_ account: Account, _ error: Error) { + if case CloudKitZoneError.userDeletedZone = error { + account.removeFeeds(account.topLevelWebFeeds) + for folder in account.folders ?? Set() { + account.removeFolder(folder) + } + } + } + } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index d46e1e8ea..e9d73bcff 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -219,6 +219,40 @@ final class CloudKitAccountZone: CloudKitZone { let predicate = NSPredicate(format: "isAccount = \"1\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) + database?.perform(ckQuery, inZoneWith: Self.zoneID) { [weak self] records, error in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if records!.count > 0 { + completion(.success(records![0].externalID)) + } else { + self.createContainer(name: "Account", isAccount: true, completion: completion) + } + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.findOrCreateAccount(completion: completion) + } + case .zoneNotFound, .userDeletedZone: + self.createZoneRecord() { result in + switch result { + case .success: + self.findOrCreateAccount(completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + query(ckQuery) { result in switch result { case .success(let records): diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 411684452..080799ed8 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -59,19 +59,63 @@ final class CloudKitArticlesZone: CloudKitZone { self.database = container.privateCloudDatabase } + func refreshArticleStatus(completion: @escaping ((Result) -> Void)) { + fetchChangesInZone() { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.refreshArticleStatus(completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + } + } + func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { var records = makeStatusRecords(syncStatuses) makeArticleRecordsIfNecessary(starredArticles) { result in switch result { case .success(let articleRecords): records.append(contentsOf: articleRecords) - self.modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + self.modify(recordsToSave: records, recordIDsToDelete: []) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) + } + } case .failure(let error): - completion(.failure(error)) + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) } } } + func handleSendArticleStatusError(_ error: Error, syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.sendArticleStatus(syncStatuses, starredArticles: starredArticles, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + } private extension CloudKitArticlesZone { diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index fb7a3269f..ee3f45474 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -16,7 +16,11 @@ enum CloudKitZoneError: LocalizedError { case unknown var errorDescription: String? { - return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + if case .userDeletedZone = self { + return NSLocalizedString("The iCloud data was deleted. Please delete the NetNewsWire iCloud account and add it again to continue using NetNewsWire's iCloud support.", comment: "User deleted zone.") + } else { + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } } } @@ -63,19 +67,12 @@ extension CloudKitZone { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func subscribeToZoneChanges() { - let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) - - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - subscription.notificationInfo = info - - 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 retryIfPossible(after: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + after + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) + } func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) @@ -91,6 +88,39 @@ extension CloudKitZone { completion() } } + + func createZoneRecord(completion: @escaping (Result) -> Void) { + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + + func subscribeToZoneChanges() { + let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + 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) + } + } + } /// Checks to see if the record described in the query exists by retrieving only the testField parameter field. func exists(_ query: CKQuery, completion: @escaping (Result) -> Void) { @@ -108,10 +138,25 @@ extension CloudKitZone { DispatchQueue.main.async { completion(.success(recordFound)) } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.exists(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.exists(query, completion: completion) } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } default: DispatchQueue.main.async { completion(.failure(CloudKitError(error!))) @@ -139,6 +184,17 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.unknown)) } } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.query(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.query(query, completion: completion) @@ -174,6 +230,17 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.unknown)) } } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.fetch(externalID: externalID, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.fetch(externalID: externalID, completion: completion) @@ -533,30 +600,4 @@ private extension CloudKitZone { return config } - func createZoneRecord(completion: @escaping (Result) -> Void) { - guard let database = database else { - completion(.failure(CloudKitZoneError.unknown)) - return - } - - database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in - if let error = error { - DispatchQueue.main.async { - completion(.failure(CloudKitError(error))) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - func retryIfPossible(after: Double, block: @escaping () -> ()) { - let delayTime = DispatchTime.now() + after - DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { - block() - }) - } - }