2020-03-22 22:35:03 +01:00
|
|
|
//
|
|
|
|
// CloudKitZone.swift
|
|
|
|
// Account
|
|
|
|
//
|
|
|
|
// Created by Maurice Parker on 3/21/20.
|
|
|
|
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import CloudKit
|
2020-03-30 00:12:34 +02:00
|
|
|
import os.log
|
|
|
|
import RSWeb
|
2020-03-22 22:35:03 +01:00
|
|
|
|
2020-03-29 18:53:52 +02:00
|
|
|
enum CloudKitZoneError: Error {
|
2020-03-29 10:43:20 +02:00
|
|
|
case userDeletedZone
|
2020-03-29 15:52:59 +02:00
|
|
|
case invalidParameter
|
2020-03-27 19:59:42 +01:00
|
|
|
case unknown
|
|
|
|
}
|
|
|
|
|
2020-03-29 18:53:52 +02:00
|
|
|
protocol CloudKitZoneDelegate: class {
|
|
|
|
func cloudKitDidChange(record: CKRecord);
|
|
|
|
func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID)
|
|
|
|
}
|
|
|
|
|
|
|
|
protocol CloudKitZone: class {
|
2020-03-22 22:35:03 +01:00
|
|
|
|
2020-03-27 19:59:42 +01:00
|
|
|
static var zoneID: CKRecordZone.ID { get }
|
|
|
|
|
2020-03-30 00:12:34 +02:00
|
|
|
var log: OSLog { get }
|
|
|
|
|
|
|
|
var container: CKContainer? { get }
|
|
|
|
var database: CKDatabase? { get }
|
|
|
|
var refreshProgress: DownloadProgress? { get set }
|
2020-03-29 18:53:52 +02:00
|
|
|
var delegate: CloudKitZoneDelegate? { get set }
|
2020-03-30 00:12:34 +02:00
|
|
|
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
extension CloudKitZone {
|
|
|
|
|
2020-03-30 00:12:34 +02:00
|
|
|
func resetChangeToken() {
|
|
|
|
changeToken = nil
|
|
|
|
}
|
|
|
|
|
2020-03-27 19:59:42 +01:00
|
|
|
func generateRecordID() -> CKRecord.ID {
|
|
|
|
return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)
|
|
|
|
}
|
|
|
|
|
2020-03-22 22:35:03 +01:00
|
|
|
func resumeLongLivedOperationIfPossible() {
|
2020-03-30 00:12:34 +02:00
|
|
|
guard let container = container else { return }
|
|
|
|
container.fetchAllLongLivedOperationIDs { (opIDs, error) in
|
|
|
|
guard let opIDs = opIDs else { return }
|
|
|
|
for opID in opIDs {
|
|
|
|
container.fetchLongLivedOperation(withID: opID, completionHandler: { (ope, error) in
|
2020-03-22 22:35:03 +01:00
|
|
|
if let modifyOp = ope as? CKModifyRecordsOperation {
|
2020-03-30 00:12:34 +02:00
|
|
|
container.add(modifyOp)
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-30 09:48:25 +02:00
|
|
|
func subscribe() {
|
|
|
|
|
|
|
|
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):
|
2020-03-31 10:30:53 +02:00
|
|
|
self.retryIfPossible(after: timeToWait) {
|
2020-03-30 09:48:25 +02:00
|
|
|
self.subscribe()
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", Self.zoneID.zoneName, error?.localizedDescription ?? "Unknown")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
|
|
|
let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo)
|
|
|
|
guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else {
|
|
|
|
completion()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
fetchChangesInZone() { result in
|
|
|
|
if case .failure(let error) = result {
|
|
|
|
os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@.", Self.zoneID.zoneName, error.localizedDescription)
|
|
|
|
}
|
|
|
|
completion()
|
|
|
|
}
|
|
|
|
}
|
2020-03-22 22:35:03 +01:00
|
|
|
|
2020-03-30 22:15:45 +02:00
|
|
|
func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) {
|
|
|
|
guard let database = database else {
|
|
|
|
completion(.failure(CloudKitZoneError.unknown))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-03-31 18:07:54 +02:00
|
|
|
refreshProgress?.addToNumberOfTasksAndRemaining(1)
|
|
|
|
|
|
|
|
database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in
|
2020-03-30 22:15:45 +02:00
|
|
|
switch CloudKitZoneResult.resolve(error) {
|
|
|
|
case .success:
|
2020-03-31 18:07:54 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self?.refreshProgress?.completeTask()
|
|
|
|
if let records = records {
|
|
|
|
completion(.success(records))
|
|
|
|
} else {
|
|
|
|
completion(.failure(CloudKitZoneError.unknown))
|
|
|
|
}
|
2020-03-30 22:15:45 +02:00
|
|
|
}
|
|
|
|
case .retry(let timeToWait):
|
2020-03-31 18:07:54 +02:00
|
|
|
self?.retryIfPossible(after: timeToWait) {
|
|
|
|
self?.query(query, completion: completion)
|
2020-03-30 22:15:45 +02:00
|
|
|
}
|
|
|
|
default:
|
2020-03-31 18:07:54 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self?.refreshProgress?.completeTask()
|
|
|
|
completion(.failure(error!))
|
|
|
|
}
|
2020-03-30 22:15:45 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-31 10:30:53 +02:00
|
|
|
func fetch(externalID: String?, completion: @escaping (Result<CKRecord, Error>) -> Void) {
|
|
|
|
guard let externalID = externalID else {
|
|
|
|
completion(.failure(CloudKitZoneError.invalidParameter))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
|
|
|
|
2020-03-31 18:07:54 +02:00
|
|
|
refreshProgress?.addToNumberOfTasksAndRemaining(1)
|
|
|
|
database?.fetch(withRecordID: recordID) { [weak self] record, error in
|
2020-03-31 10:30:53 +02:00
|
|
|
switch CloudKitZoneResult.resolve(error) {
|
|
|
|
case .success:
|
|
|
|
DispatchQueue.main.async {
|
2020-03-31 18:07:54 +02:00
|
|
|
self?.refreshProgress?.completeTask()
|
2020-03-31 10:30:53 +02:00
|
|
|
if let record = record {
|
|
|
|
completion(.success(record))
|
|
|
|
} else {
|
|
|
|
completion(.failure(CloudKitZoneError.unknown))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
case .retry(let timeToWait):
|
2020-03-31 18:07:54 +02:00
|
|
|
self?.retryIfPossible(after: timeToWait) {
|
|
|
|
self?.fetch(externalID: externalID, completion: completion)
|
2020-03-31 10:30:53 +02:00
|
|
|
}
|
|
|
|
default:
|
|
|
|
DispatchQueue.main.async {
|
2020-03-31 18:07:54 +02:00
|
|
|
self?.refreshProgress?.completeTask()
|
2020-03-31 10:30:53 +02:00
|
|
|
completion(.failure(error!))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func save(_ record: CKRecord, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
|
|
|
|
}
|
|
|
|
|
|
|
|
func delete(externalID: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
guard let externalID = externalID else {
|
|
|
|
completion(.failure(CloudKitZoneError.invalidParameter))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
|
|
|
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
|
|
|
|
}
|
|
|
|
|
2020-03-29 18:53:52 +02:00
|
|
|
func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
|
2020-03-29 15:52:59 +02:00
|
|
|
let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
|
2020-03-22 22:35:03 +01:00
|
|
|
|
|
|
|
let config = CKOperation.Configuration()
|
|
|
|
config.isLongLived = true
|
2020-03-27 19:59:42 +01:00
|
|
|
op.configuration = config
|
2020-03-22 22:35:03 +01:00
|
|
|
|
|
|
|
// We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first
|
|
|
|
// Apple suggests using .ifServerRecordUnchanged save policy
|
|
|
|
// For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/)
|
2020-03-27 19:59:42 +01:00
|
|
|
op.savePolicy = .changedKeys
|
2020-03-22 22:35:03 +01:00
|
|
|
|
|
|
|
// To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail)
|
|
|
|
// If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate
|
2020-03-27 19:59:42 +01:00
|
|
|
op.isAtomic = true
|
2020-03-22 22:35:03 +01:00
|
|
|
|
2020-03-27 19:59:42 +01:00
|
|
|
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
2020-03-22 22:35:03 +01:00
|
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
2020-03-29 10:43:20 +02:00
|
|
|
switch CloudKitZoneResult.resolve(error) {
|
2020-03-22 22:35:03 +01:00
|
|
|
case .success:
|
|
|
|
DispatchQueue.main.async {
|
2020-03-31 18:07:54 +02:00
|
|
|
self.refreshProgress?.completeTask()
|
2020-03-27 19:59:42 +01:00
|
|
|
completion(.success(()))
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
2020-03-29 10:43:20 +02:00
|
|
|
case .zoneNotFound:
|
2020-03-28 14:30:25 +01:00
|
|
|
self.createZoneRecord() { result in
|
|
|
|
switch result {
|
|
|
|
case .success:
|
2020-03-29 15:52:59 +02:00
|
|
|
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
2020-03-28 14:30:25 +01:00
|
|
|
case .failure(let error):
|
2020-03-31 18:07:54 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.refreshProgress?.completeTask()
|
|
|
|
completion(.failure(error))
|
|
|
|
}
|
2020-03-28 14:30:25 +01:00
|
|
|
}
|
|
|
|
}
|
2020-03-29 10:43:20 +02:00
|
|
|
case .userDeletedZone:
|
|
|
|
DispatchQueue.main.async {
|
2020-03-31 18:07:54 +02:00
|
|
|
self.refreshProgress?.completeTask()
|
2020-03-29 10:43:20 +02:00
|
|
|
completion(.failure(CloudKitZoneError.userDeletedZone))
|
|
|
|
}
|
2020-03-27 19:59:42 +01:00
|
|
|
case .retry(let timeToWait):
|
2020-03-31 10:30:53 +02:00
|
|
|
self.retryIfPossible(after: timeToWait) {
|
2020-03-29 15:52:59 +02:00
|
|
|
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
2020-03-29 10:43:20 +02:00
|
|
|
case .limitExceeded:
|
2020-03-29 15:52:59 +02:00
|
|
|
let chunkedRecords = recordsToSave.chunked(into: 300)
|
2020-03-22 22:35:03 +01:00
|
|
|
for chunk in chunkedRecords {
|
2020-03-29 15:52:59 +02:00
|
|
|
self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
|
|
|
default:
|
2020-03-29 10:43:20 +02:00
|
|
|
DispatchQueue.main.async {
|
2020-03-31 18:07:54 +02:00
|
|
|
self.refreshProgress?.completeTask()
|
2020-03-29 10:43:20 +02:00
|
|
|
completion(.failure(error!))
|
|
|
|
}
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
|
|
|
}
|
2020-03-31 18:07:54 +02:00
|
|
|
|
|
|
|
refreshProgress?.addToNumberOfTasksAndRemaining(1)
|
2020-03-30 00:12:34 +02:00
|
|
|
database?.add(op)
|
2020-03-27 19:59:42 +01:00
|
|
|
}
|
|
|
|
|
2020-03-30 00:12:34 +02:00
|
|
|
func fetchChangesInZone(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
2020-03-29 18:53:52 +02:00
|
|
|
let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
|
|
|
zoneConfig.previousServerChangeToken = changeToken
|
|
|
|
let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig])
|
|
|
|
op.fetchAllChanges = true
|
|
|
|
|
2020-03-30 09:48:25 +02:00
|
|
|
op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneID, token, _ in
|
2020-03-29 18:53:52 +02:00
|
|
|
guard let self = self else { return }
|
2020-03-30 00:12:34 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.changeToken = token
|
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
op.recordChangedBlock = { [weak self] record in
|
|
|
|
guard let self = self else { return }
|
2020-03-30 00:12:34 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.delegate?.cloudKitDidChange(record: record)
|
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
|
2020-03-30 09:48:25 +02:00
|
|
|
op.recordWithIDWasDeletedBlock = { [weak self] recordID, recordType in
|
2020-03-29 18:53:52 +02:00
|
|
|
guard let self = self else { return }
|
2020-03-30 00:12:34 +02:00
|
|
|
DispatchQueue.main.async {
|
2020-03-30 09:48:25 +02:00
|
|
|
self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordID)
|
2020-03-30 00:12:34 +02:00
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
|
2020-03-30 09:48:25 +02:00
|
|
|
op.recordZoneFetchCompletionBlock = { [weak self] zoneID ,token, _, _, error in
|
2020-03-29 18:53:52 +02:00
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
|
|
switch CloudKitZoneResult.resolve(error) {
|
|
|
|
case .success:
|
2020-03-30 00:12:34 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.changeToken = token
|
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
case .retry(let timeToWait):
|
2020-03-31 10:30:53 +02:00
|
|
|
self.retryIfPossible(after: timeToWait) {
|
2020-03-30 00:12:34 +02:00
|
|
|
self.fetchChangesInZone(completion: completion)
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
default:
|
2020-03-30 09:48:25 +02:00
|
|
|
os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneID.zoneName, error?.localizedDescription ?? "Unknown")
|
2020-03-30 00:12:34 +02:00
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
|
2020-03-30 00:12:34 +02:00
|
|
|
op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self?.refreshProgress?.completeTask()
|
|
|
|
if let error = error {
|
|
|
|
completion(.failure(error))
|
|
|
|
} else {
|
|
|
|
completion(.success(()))
|
|
|
|
}
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-31 18:07:54 +02:00
|
|
|
refreshProgress?.addToNumberOfTasksAndRemaining(1)
|
2020-03-30 00:12:34 +02:00
|
|
|
database?.add(op)
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
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, Error>) -> Void) {
|
2020-03-30 22:15:45 +02:00
|
|
|
guard let database = database else {
|
|
|
|
completion(.failure(CloudKitZoneError.unknown))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
|
2020-03-29 18:53:52 +02:00
|
|
|
if let error = error {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
completion(.failure(error))
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
completion(.success(()))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-31 10:30:53 +02:00
|
|
|
func retryIfPossible(after: Double, block: @escaping () -> ()) {
|
|
|
|
let delayTime = DispatchTime.now() + after
|
2020-03-27 19:59:42 +01:00
|
|
|
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: {
|
|
|
|
block()
|
|
|
|
})
|
2020-03-22 22:35:03 +01:00
|
|
|
}
|
|
|
|
|
2020-03-29 18:53:52 +02:00
|
|
|
}
|