session-ios/Session/Signal/OWSBackupAPI.swift

740 lines
32 KiB
Swift
Raw Normal View History

2018-03-06 14:29:25 +01:00
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
2018-03-06 14:29:25 +01:00
//
import Foundation
2020-11-11 07:45:50 +01:00
import SignalUtilitiesKit
2018-03-06 14:29:25 +01:00
import CloudKit
2018-11-27 17:15:09 +01:00
import PromiseKit
2018-03-06 14:29:25 +01:00
2018-03-20 22:22:19 +01:00
// We don't worry about atomic writes. Each backup export
// will diff against last successful backup.
//
// Note that all of our CloudKit records are immutable.
// "Persistent" records are only uploaded once.
// "Ephemeral" records are always uploaded to a new record name.
2018-03-06 14:29:25 +01:00
@objc public class OWSBackupAPI: NSObject {
// If we change the record types, we need to ensure indices
// are configured properly in the CloudKit dashboard.
//
// TODO: Change the record types when we ship to production.
static let signalBackupRecordType = "signalBackup"
static let manifestRecordNameSuffix = "manifest"
static let payloadKey = "payload"
2018-03-13 18:39:00 +01:00
static let maxRetries = 5
2018-03-13 18:05:51 +01:00
private class func database() -> CKDatabase {
let myContainer = CKContainer.default()
let privateDatabase = myContainer.privateCloudDatabase
return privateDatabase
}
2018-03-13 18:39:00 +01:00
private class func invalidServiceResponseError() -> Error {
return OWSErrorWithCodeDescription(.backupFailure,
NSLocalizedString("BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE",
comment: "Error indicating that the app received an invalid response from CloudKit."))
}
2018-03-13 18:05:51 +01:00
// MARK: - Upload
2018-03-06 14:29:25 +01:00
@objc
2018-11-30 22:46:23 +01:00
public class func recordNameForTestFile(recipientId: String) -> String {
return "\(recordNamePrefix(forRecipientId: recipientId))test-\(NSUUID().uuidString)"
}
2018-11-30 22:07:23 +01:00
// "Ephemeral" files are specific to this backup export and will always need to
// be saved. For example, a complete image of the database is exported each time.
// We wouldn't want to overwrite previous images until the entire backup export is
// complete.
@objc
public class func recordNameForEphemeralFile(recipientId: String,
label: String) -> String {
return "\(recordNamePrefix(forRecipientId: recipientId))ephemeral-\(label)-\(NSUUID().uuidString)"
}
2018-03-17 21:29:57 +01:00
// "Persistent" files may be shared between backup export; they should only be saved
// once. For example, attachment files should only be uploaded once. Subsequent
// backups can reuse the same record.
@objc
public class func recordNameForPersistentFile(recipientId: String,
fileId: String) -> String {
return "\(recordNamePrefix(forRecipientId: recipientId))persistentFile-\(fileId)"
2018-03-17 21:29:57 +01:00
}
2018-03-06 19:58:06 +01:00
// "Persistent" files may be shared between backup export; they should only be saved
// once. For example, attachment files should only be uploaded once. Subsequent
// backups can reuse the same record.
2018-03-06 19:58:06 +01:00
@objc
public class func recordNameForManifest(recipientId: String) -> String {
return "\(recordNamePrefix(forRecipientId: recipientId))\(manifestRecordNameSuffix)"
}
private class func isManifest(recordName: String) -> Bool {
return recordName.hasSuffix(manifestRecordNameSuffix)
}
private class func recordNamePrefix(forRecipientId recipientId: String) -> String {
2018-11-27 15:43:32 +01:00
return "\(recipientId)-"
}
private class func recipientId(forRecordName recordName: String) -> String? {
let recipientIds = self.recipientIds(forRecordNames: [recordName])
guard let recipientId = recipientIds.first else {
return nil
}
return recipientId
}
2018-11-27 15:43:32 +01:00
private static var recordNamePrefixRegex = {
return try! NSRegularExpression(pattern: "^(\\+[0-9]+)\\-")
}()
private class func recipientIds(forRecordNames recordNames: [String]) -> [String] {
var recipientIds = [String]()
for recordName in recordNames {
2018-11-27 15:43:32 +01:00
let regex = recordNamePrefixRegex
guard let match: NSTextCheckingResult = regex.firstMatch(in: recordName, options: [], range: NSRange(location: 0, length: recordName.utf16.count)) else {
2018-11-27 15:43:32 +01:00
Logger.warn("no match: \(recordName)")
continue
}
guard match.numberOfRanges > 0 else {
// Match must include first group.
Logger.warn("invalid match: \(recordName)")
continue
}
2018-11-27 15:43:32 +01:00
let firstRange = match.range(at: 1)
guard firstRange.location == 0,
firstRange.length > 0 else {
2018-11-27 22:12:46 +01:00
// Match must be at start of string and non-empty.
Logger.warn("invalid match: \(recordName) \(firstRange)")
continue
}
2018-11-27 15:43:32 +01:00
let recipientId = (recordName as NSString).substring(with: firstRange) as String
recipientIds.append(recipientId)
}
return recipientIds
}
2018-11-30 22:07:23 +01:00
@objc
public class func record(forFileUrl fileUrl: URL,
recordName: String) -> CKRecord {
let recordType = signalBackupRecordType
2019-03-30 14:22:31 +01:00
let recordID = CKRecord.ID(recordName: recordName)
2018-11-30 22:07:23 +01:00
let record = CKRecord(recordType: recordType, recordID: recordID)
let asset = CKAsset(fileURL: fileUrl)
record[payloadKey] = asset
return record
}
@objc
public class func saveRecordsToCloudObjc(records: [CKRecord]) -> AnyPromise {
return AnyPromise(saveRecordsToCloud(records: records))
}
public class func saveRecordsToCloud(records: [CKRecord]) -> Promise<Void> {
2018-11-30 23:17:56 +01:00
// CloudKit's internal limit is 400, but I haven't found a constant for this.
let kMaxBatchSize = 100
2018-12-05 16:00:36 +01:00
return records.chunked(by: kMaxBatchSize).reduce(Promise.value(())) { (promise, batch) -> Promise<Void> in
return promise.then(on: .global()) {
saveRecordsToCloud(records: batch, remainingRetries: maxRetries)
}.done {
Logger.verbose("Saved batch: \(batch.count)")
2018-11-30 23:17:56 +01:00
}
}
2018-11-30 22:07:23 +01:00
}
private class func saveRecordsToCloud(records: [CKRecord],
remainingRetries: Int) -> Promise<Void> {
let recordNames = records.map { (record) in
return record.recordID.recordName
}
2018-11-30 23:17:56 +01:00
Logger.verbose("recordNames[\(recordNames.count)] \(recordNames[0..<10])...")
2018-11-30 22:07:23 +01:00
return Promise { resolver in
let saveOperation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
saveOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, _, error) in
let retry = {
// Only retry records which didn't already succeed.
var savedRecordNames = [String]()
if let savedRecords = savedRecords {
savedRecordNames = savedRecords.map { (record) in
return record.recordID.recordName
}
}
let retryRecords = records.filter({ (record) in
return !savedRecordNames.contains(record.recordID.recordName)
})
saveRecordsToCloud(records: retryRecords,
remainingRetries: remainingRetries - 1)
.done { _ in
resolver.fulfill(())
}.catch { (error) in
resolver.reject(error)
}.retainUntilComplete()
}
let outcome = outcomeForCloudKitError(error: error,
remainingRetries: remainingRetries,
2018-11-30 23:17:56 +01:00
label: "Save Records[\(recordNames.count)]")
2018-11-30 22:07:23 +01:00
switch outcome {
case .success:
resolver.fulfill(())
case .failureDoNotRetry(let outcomeError):
resolver.reject(outcomeError)
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
retry()
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
retry()
}
case .unknownItem:
owsFailDebug("unexpected CloudKit response.")
resolver.reject(invalidServiceResponseError())
}
}
saveOperation.isAtomic = false
saveOperation.savePolicy = .allKeys
// TODO: use perRecordProgressBlock and perRecordCompletionBlock.
// open var perRecordProgressBlock: ((CKRecord, Double) -> Void)?
// open var perRecordCompletionBlock: ((CKRecord, Error?) -> Void)?
// These APIs are only available in iOS 9.3 and later.
if #available(iOS 9.3, *) {
saveOperation.isLongLived = true
saveOperation.qualityOfService = .background
}
database().add(saveOperation)
}
}
2018-03-13 18:05:51 +01:00
// MARK: - Delete
@objc
public class func deleteRecordsFromCloud(recordNames: [String],
2018-11-27 22:12:46 +01:00
success: @escaping () -> Void,
failure: @escaping (Error) -> Void) {
deleteRecordsFromCloud(recordNames: recordNames,
2018-11-27 22:12:46 +01:00
remainingRetries: maxRetries,
success: success,
failure: failure)
2018-03-13 18:05:51 +01:00
}
private class func deleteRecordsFromCloud(recordNames: [String],
2018-11-27 22:12:46 +01:00
remainingRetries: Int,
success: @escaping () -> Void,
failure: @escaping (Error) -> Void) {
2018-03-13 18:05:51 +01:00
2019-03-30 14:22:31 +01:00
let recordIDs = recordNames.map { CKRecord.ID(recordName: $0) }
let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs)
deleteOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in
2018-03-13 18:05:51 +01:00
2018-03-14 14:12:20 +01:00
let outcome = outcomeForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Delete Records")
2018-03-14 14:12:20 +01:00
switch outcome {
2018-03-13 18:05:51 +01:00
case .success:
2018-05-25 21:21:43 +02:00
success()
2018-03-14 14:12:20 +01:00
case .failureDoNotRetry(let outcomeError):
failure(outcomeError)
2018-03-13 18:05:51 +01:00
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
deleteRecordsFromCloud(recordNames: recordNames,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
2018-03-13 18:05:51 +01:00
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
deleteRecordsFromCloud(recordNames: recordNames,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
2018-03-13 18:05:51 +01:00
}
2018-03-13 18:39:00 +01:00
case .unknownItem:
2018-08-27 16:27:48 +02:00
owsFailDebug("unexpected CloudKit response.")
2018-03-13 18:39:00 +01:00
failure(invalidServiceResponseError())
2018-03-13 18:05:51 +01:00
}
}
database().add(deleteOperation)
2018-03-13 18:05:51 +01:00
}
// MARK: - Exists?
2018-03-08 14:31:35 +01:00
private class func checkForFileInCloud(recordName: String,
2018-11-27 22:12:46 +01:00
remainingRetries: Int) -> Promise<CKRecord?> {
2018-11-29 23:18:26 +01:00
Logger.verbose("checkForFileInCloud \(recordName)")
2018-11-27 22:12:46 +01:00
let (promise, resolver) = Promise<CKRecord?>.pending()
2019-03-30 14:22:31 +01:00
let recordId = CKRecord.ID(recordName: recordName)
let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ])
// Don't download the file; we're just using the fetch to check whether or
// not this record already exists.
fetchOperation.desiredKeys = []
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
2018-03-13 18:39:00 +01:00
2018-03-14 14:12:20 +01:00
let outcome = outcomeForCloudKitError(error: error,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries,
label: "Check for Record")
2018-03-14 14:12:20 +01:00
switch outcome {
2018-03-13 18:39:00 +01:00
case .success:
guard let record = record else {
2018-08-27 16:27:48 +02:00
owsFailDebug("missing fetching record.")
2018-11-27 22:12:46 +01:00
resolver.reject(invalidServiceResponseError())
2018-03-13 18:39:00 +01:00
return
}
2018-03-13 18:39:00 +01:00
// Record found.
2018-11-27 22:12:46 +01:00
resolver.fulfill(record)
2018-03-14 14:12:20 +01:00
case .failureDoNotRetry(let outcomeError):
2018-11-27 22:12:46 +01:00
resolver.reject(outcomeError)
2018-03-13 18:39:00 +01:00
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
checkForFileInCloud(recordName: recordName,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries - 1)
.done { (record) in
resolver.fulfill(record)
}.catch { (error) in
resolver.reject(error)
2018-11-28 23:01:26 +01:00
}.retainUntilComplete()
2018-03-13 18:39:00 +01:00
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
checkForFileInCloud(recordName: recordName,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries - 1)
.done { (record) in
resolver.fulfill(record)
}.catch { (error) in
resolver.reject(error)
2018-11-28 23:01:26 +01:00
}.retainUntilComplete()
2018-03-13 18:39:00 +01:00
}
case .unknownItem:
// Record not found.
2018-11-27 22:12:46 +01:00
resolver.fulfill(nil)
}
2018-03-06 19:58:06 +01:00
}
2018-03-13 18:05:51 +01:00
database().add(fetchOperation)
2018-11-28 23:01:26 +01:00
return promise
2018-03-06 19:58:06 +01:00
}
@objc
2018-11-27 20:01:25 +01:00
public class func checkForManifestInCloudObjc(recipientId: String) -> AnyPromise {
return AnyPromise(checkForManifestInCloud(recipientId: recipientId))
}
public class func checkForManifestInCloud(recipientId: String) -> Promise<Bool> {
2018-03-08 14:31:35 +01:00
let recordName = recordNameForManifest(recipientId: recipientId)
2018-11-27 22:12:46 +01:00
return checkForFileInCloud(recordName: recordName,
remainingRetries: maxRetries)
.map { (record) in
return record != nil
}
}
@objc
public class func allRecipientIdsWithManifestsInCloud(success: @escaping ([String]) -> Void,
failure: @escaping (Error) -> Void) {
let processResults = { (recordNames: [String]) in
DispatchQueue.global().async {
let manifestRecordNames = recordNames.filter({ (recordName) -> Bool in
self.isManifest(recordName: recordName)
})
let recipientIds = self.recipientIds(forRecordNames: manifestRecordNames)
success(recipientIds)
}
}
let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true))
// Fetch the first page of results for this query.
fetchAllRecordNamesStep(recipientId: nil,
query: query,
previousRecordNames: [String](),
cursor: nil,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
success: processResults,
failure: failure)
}
@objc
public class func fetchAllRecordNames(recipientId: String,
success: @escaping ([String]) -> Void,
failure: @escaping (Error) -> Void) {
let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true))
// Fetch the first page of results for this query.
fetchAllRecordNamesStep(recipientId: recipientId,
query: query,
previousRecordNames: [String](),
cursor: nil,
remainingRetries: maxRetries,
success: success,
failure: failure)
}
private class func fetchAllRecordNamesStep(recipientId: String?,
query: CKQuery,
previousRecordNames: [String],
2019-03-30 14:22:31 +01:00
cursor: CKQueryOperation.Cursor?,
2018-03-13 18:39:00 +01:00
remainingRetries: Int,
2018-03-14 14:12:20 +01:00
success: @escaping ([String]) -> Void,
failure: @escaping (Error) -> Void) {
var allRecordNames = previousRecordNames
2018-03-13 18:05:51 +01:00
let queryOperation = CKQueryOperation(query: query)
// If this isn't the first page of results for this query, resume
// where we left off.
queryOperation.cursor = cursor
// Don't download the file; we're just using the query to get a list of record names.
queryOperation.desiredKeys = []
queryOperation.recordFetchedBlock = { (record) in
assert(record.recordID.recordName.count > 0)
let recordName = record.recordID.recordName
if let recipientId = recipientId {
let prefix = recordNamePrefix(forRecipientId: recipientId)
guard recordName.hasPrefix(prefix) else {
Logger.info("Ignoring record: \(recordName)")
return
}
}
allRecordNames.append(recordName)
}
queryOperation.queryCompletionBlock = { (cursor, error) in
2018-03-13 18:39:00 +01:00
2018-03-14 14:12:20 +01:00
let outcome = outcomeForCloudKitError(error: error,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries,
label: "Fetch All Records")
2018-03-14 14:12:20 +01:00
switch outcome {
2018-03-13 18:39:00 +01:00
case .success:
if let cursor = cursor {
2018-08-23 16:37:34 +02:00
Logger.verbose("fetching more record names \(allRecordNames.count).")
2018-03-13 18:39:00 +01:00
// There are more pages of results, continue fetching.
fetchAllRecordNamesStep(recipientId: recipientId,
query: query,
2018-03-13 18:39:00 +01:00
previousRecordNames: allRecordNames,
cursor: cursor,
remainingRetries: maxRetries,
success: success,
failure: failure)
return
}
2018-08-23 16:37:34 +02:00
Logger.info("fetched \(allRecordNames.count) record names.")
2018-03-13 18:39:00 +01:00
success(allRecordNames)
2018-03-14 14:12:20 +01:00
case .failureDoNotRetry(let outcomeError):
failure(outcomeError)
2018-03-13 18:39:00 +01:00
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
fetchAllRecordNamesStep(recipientId: recipientId,
query: query,
2018-03-13 18:39:00 +01:00
previousRecordNames: allRecordNames,
cursor: cursor,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
fetchAllRecordNamesStep(recipientId: recipientId,
query: query,
2018-03-13 18:39:00 +01:00
previousRecordNames: allRecordNames,
cursor: cursor,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
}
case .unknownItem:
2018-08-27 16:27:48 +02:00
owsFailDebug("unexpected CloudKit response.")
2018-03-13 18:39:00 +01:00
failure(invalidServiceResponseError())
}
}
2018-03-13 18:05:51 +01:00
database().add(queryOperation)
}
2018-03-13 18:05:51 +01:00
// MARK: - Download
2018-03-08 19:38:42 +01:00
@objc
2018-11-28 21:49:45 +01:00
public class func downloadManifestFromCloudObjc(recipientId: String) -> AnyPromise {
return AnyPromise(downloadManifestFromCloud(recipientId: recipientId))
}
public class func downloadManifestFromCloud(recipientId: String) -> Promise<Data> {
let recordName = recordNameForManifest(recipientId: recipientId)
2018-11-28 21:49:45 +01:00
return downloadDataFromCloud(recordName: recordName)
2018-03-08 19:38:42 +01:00
}
@objc
2018-11-28 21:49:45 +01:00
public class func downloadDataFromCloudObjc(recordName: String) -> AnyPromise {
return AnyPromise(downloadDataFromCloud(recordName: recordName))
}
public class func downloadDataFromCloud(recordName: String) -> Promise<Data> {
return downloadFromCloud(recordName: recordName,
remainingRetries: maxRetries)
.map { (asset) -> Data in
guard let fileURL = asset.fileURL else {
throw invalidServiceResponseError()
2018-11-28 21:49:45 +01:00
}
return try Data(contentsOf: fileURL)
2018-11-28 21:49:45 +01:00
}
2018-03-08 19:38:42 +01:00
}
@objc
2018-11-28 21:49:45 +01:00
public class func downloadFileFromCloudObjc(recordName: String,
2018-11-28 21:55:18 +01:00
toFileUrl: URL) -> AnyPromise {
2018-11-28 21:49:45 +01:00
return AnyPromise(downloadFileFromCloud(recordName: recordName,
toFileUrl: toFileUrl))
}
2018-03-08 19:38:42 +01:00
public class func downloadFileFromCloud(recordName: String,
2018-11-28 21:49:45 +01:00
toFileUrl: URL) -> Promise<Void> {
return downloadFromCloud(recordName: recordName,
2018-11-28 21:55:18 +01:00
remainingRetries: maxRetries)
.done { asset in
guard let fileURL = asset.fileURL else {
throw invalidServiceResponseError()
2018-11-28 21:49:45 +01:00
}
try FileManager.default.copyItem(at: fileURL, to: toFileUrl)
2018-11-28 21:49:45 +01:00
}
2018-03-08 19:38:42 +01:00
}
2018-03-13 18:39:00 +01:00
// We return the CKAsset and not its fileUrl because
// CloudKit offers no guarantees around how long it'll
// keep around the underlying file. Presumably we can
// defer cleanup by maintaining a strong reference to
// the asset.
2018-03-08 19:38:42 +01:00
private class func downloadFromCloud(recordName: String,
2018-11-28 21:49:45 +01:00
remainingRetries: Int) -> Promise<CKAsset> {
2018-11-29 23:18:26 +01:00
Logger.verbose("downloadFromCloud \(recordName)")
2018-11-28 21:49:45 +01:00
let (promise, resolver) = Promise<CKAsset>.pending()
2018-03-08 19:38:42 +01:00
2019-03-30 14:22:31 +01:00
let recordId = CKRecord.ID(recordName: recordName)
2018-03-08 19:38:42 +01:00
let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ])
// Download all keys for this record.
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
2018-03-13 18:39:00 +01:00
2018-03-14 14:12:20 +01:00
let outcome = outcomeForCloudKitError(error: error,
2018-11-27 22:12:46 +01:00
remainingRetries: remainingRetries,
label: "Download Record")
2018-03-14 14:12:20 +01:00
switch outcome {
2018-03-13 18:39:00 +01:00
case .success:
guard let record = record else {
2018-08-23 16:37:34 +02:00
Logger.error("missing fetching record.")
2018-11-28 21:49:45 +01:00
resolver.reject(invalidServiceResponseError())
2018-03-13 18:39:00 +01:00
return
}
guard let asset = record[payloadKey] as? CKAsset else {
2018-08-23 16:37:34 +02:00
Logger.error("record missing payload.")
2018-11-28 21:49:45 +01:00
resolver.reject(invalidServiceResponseError())
2018-03-13 18:39:00 +01:00
return
}
2018-11-28 21:49:45 +01:00
resolver.fulfill(asset)
2018-03-14 14:12:20 +01:00
case .failureDoNotRetry(let outcomeError):
2018-11-28 21:49:45 +01:00
resolver.reject(outcomeError)
2018-03-13 18:39:00 +01:00
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
downloadFromCloud(recordName: recordName,
2018-11-28 21:49:45 +01:00
remainingRetries: remainingRetries - 1)
.done { (asset) in
resolver.fulfill(asset)
}.catch { (error) in
resolver.reject(error)
2018-11-28 21:55:18 +01:00
}.retainUntilComplete()
2018-03-13 18:39:00 +01:00
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
downloadFromCloud(recordName: recordName,
2018-11-28 21:49:45 +01:00
remainingRetries: remainingRetries - 1)
.done { (asset) in
resolver.fulfill(asset)
}.catch { (error) in
resolver.reject(error)
}.retainUntilComplete()
2018-03-13 18:39:00 +01:00
}
case .unknownItem:
2018-08-23 16:37:34 +02:00
Logger.error("missing fetching record.")
2018-11-28 21:49:45 +01:00
resolver.reject(invalidServiceResponseError())
2018-03-08 19:38:42 +01:00
}
}
2018-03-13 18:05:51 +01:00
database().add(fetchOperation)
2018-11-28 21:49:45 +01:00
return promise
2018-03-08 19:38:42 +01:00
}
2018-03-13 18:05:51 +01:00
// MARK: - Access
2018-11-27 17:15:09 +01:00
@objc public enum BackupError: Int, Error {
case couldNotDetermineAccountStatus
case noAccount
case restrictedAccountStatus
}
2018-03-06 14:29:25 +01:00
@objc
2018-11-28 23:01:26 +01:00
public class func ensureCloudKitAccessObjc() -> AnyPromise {
return AnyPromise(ensureCloudKitAccess())
2018-11-27 17:15:09 +01:00
}
2018-11-28 23:01:26 +01:00
public class func ensureCloudKitAccess() -> Promise<Void> {
2018-11-27 17:15:09 +01:00
let (promise, resolver) = Promise<Void>.pending()
2018-11-28 23:01:26 +01:00
CKContainer.default().accountStatus { (accountStatus, error) in
2018-11-27 20:01:25 +01:00
if let error = error {
Logger.error("Unknown error: \(String(describing: error)).")
resolver.reject(error)
return
}
switch accountStatus {
case .couldNotDetermine:
Logger.error("could not determine CloudKit account status: \(String(describing: error)).")
resolver.reject(BackupError.couldNotDetermineAccountStatus)
case .noAccount:
Logger.error("no CloudKit account.")
resolver.reject(BackupError.noAccount)
case .restricted:
Logger.error("restricted CloudKit account.")
resolver.reject(BackupError.restrictedAccountStatus)
case .available:
Logger.verbose("CloudKit access okay.")
resolver.fulfill(())
2018-03-06 14:29:25 +01:00
}
2018-11-28 23:01:26 +01:00
}
2018-11-27 17:15:09 +01:00
return promise
}
@objc
public class func errorMessage(forCloudKitAccessError error: Error) -> String {
if let backupError = error as? BackupError {
Logger.error("Backup error: \(String(describing: backupError)).")
switch backupError {
case .couldNotDetermineAccountStatus:
return NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's iCloud account status")
case .noAccount:
return NSLocalizedString("CLOUDKIT_STATUS_NO_ACCOUNT", comment: "Error indicating that user does not have an iCloud account.")
case .restrictedAccountStatus:
return NSLocalizedString("CLOUDKIT_STATUS_RESTRICTED", comment: "Error indicating that the app was prevented from accessing the user's iCloud account.")
}
} else {
Logger.error("Unknown error: \(String(describing: error)).")
return NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's iCloud account status")
}
2018-03-06 14:29:25 +01:00
}
2018-03-13 18:05:51 +01:00
// MARK: - Retry
2018-03-17 12:50:09 +01:00
private enum APIOutcome {
2018-03-13 18:05:51 +01:00
case success
case failureDoNotRetry(error:Error)
2018-03-17 12:50:09 +01:00
case failureRetryAfterDelay(retryDelay: TimeInterval)
2018-03-13 18:05:51 +01:00
case failureRetryWithoutDelay
2018-03-13 18:39:00 +01:00
// This only applies to fetches.
case unknownItem
2018-03-13 18:05:51 +01:00
}
2018-03-14 14:12:20 +01:00
private class func outcomeForCloudKitError(error: Error?,
2018-11-27 22:12:46 +01:00
remainingRetries: Int,
label: String) -> APIOutcome {
2018-03-13 18:05:51 +01:00
if let error = error as? CKError {
2018-03-13 18:39:00 +01:00
if error.code == CKError.unknownItem {
// This is not always an error for our purposes.
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) unknown item.")
2018-03-13 18:39:00 +01:00
return .unknownItem
}
2018-08-23 16:37:34 +02:00
Logger.error("\(label) failed: \(error)")
2018-03-13 18:39:00 +01:00
2018-03-13 18:05:51 +01:00
if remainingRetries < 1 {
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) no more retries.")
2018-03-13 18:05:51 +01:00
return .failureDoNotRetry(error:error)
}
if #available(iOS 11, *) {
if error.code == CKError.serverResponseLost {
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) retry without delay.")
2018-03-13 18:05:51 +01:00
return .failureRetryWithoutDelay
}
}
switch error {
case CKError.requestRateLimited, CKError.serviceUnavailable, CKError.zoneBusy:
let retryDelay = error.retryAfterSeconds ?? 3.0
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) retry with delay: \(retryDelay).")
2018-03-13 18:05:51 +01:00
return .failureRetryAfterDelay(retryDelay:retryDelay)
case CKError.networkFailure:
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) retry without delay.")
2018-03-13 18:05:51 +01:00
return .failureRetryWithoutDelay
default:
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) unknown CKError.")
2018-03-13 18:05:51 +01:00
return .failureDoNotRetry(error:error)
}
} else if let error = error {
2018-08-23 16:37:34 +02:00
Logger.error("\(label) failed: \(error)")
2018-03-13 18:05:51 +01:00
if remainingRetries < 1 {
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) no more retries.")
2018-03-13 18:05:51 +01:00
return .failureDoNotRetry(error:error)
}
2018-08-23 16:37:34 +02:00
Logger.verbose("\(label) unknown error.")
2018-03-13 18:05:51 +01:00
return .failureDoNotRetry(error:error)
} else {
2018-08-23 16:37:34 +02:00
Logger.info("\(label) succeeded.")
2018-03-13 18:05:51 +01:00
return .success
}
}
// MARK: -
@objc
public class func setup() {
cancelAllLongLivedOperations()
}
private class func cancelAllLongLivedOperations() {
// These APIs are only available in iOS 9.3 and later.
guard #available(iOS 9.3, *) else {
return
}
let container = CKContainer.default()
container.fetchAllLongLivedOperationIDs { (operationIds, error) in
if let error = error {
Logger.error("Could not get all long lived operations: \(error)")
return
}
guard let operationIds = operationIds else {
Logger.error("No operation ids.")
return
}
for operationId in operationIds {
container.fetchLongLivedOperation(withID: operationId, completionHandler: { (operation, error) in
if let error = error {
Logger.error("Could not get long lived operation [\(operationId)]: \(error)")
return
}
guard let operation = operation else {
Logger.error("No operation.")
return
}
operation.cancel()
})
}
}
}
2018-03-06 14:29:25 +01:00
}