// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit import CloudKit // 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. @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 manifestRecordName = "manifest" static let payloadKey = "payload" static let maxRetries = 5 private class func recordIdForTest() -> String { return "test-\(NSUUID().uuidString)" } private class func database() -> CKDatabase { let myContainer = CKContainer.default() let privateDatabase = myContainer.privateCloudDatabase return privateDatabase } 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.")) } // MARK: - Upload @objc public class func saveTestFileToCloud(fileUrl: URL, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { saveFileToCloud(fileUrl: fileUrl, recordName: NSUUID().uuidString, recordType: signalBackupRecordType, success: success, failure: failure) } // "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 saveEphemeralDatabaseFileToCloud(fileUrl: URL, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { saveFileToCloud(fileUrl: fileUrl, recordName: "ephemeralFile-\(NSUUID().uuidString)", recordType: signalBackupRecordType, success: success, failure: failure) } // "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(fileId: String) -> String { return "persistentFile-\(fileId)" } // "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 savePersistentFileOnceToCloud(fileId: String, fileUrlBlock: @escaping (()) -> URL?, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { saveFileOnceToCloud(recordName: recordNameForPersistentFile(fileId: fileId), recordType: signalBackupRecordType, fileUrlBlock: fileUrlBlock, success: success, failure: failure) } @objc public class func upsertManifestFileToCloud(fileUrl: URL, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { // We want to use a well-known record id and type for manifest files. upsertFileToCloud(fileUrl: fileUrl, recordName: manifestRecordName, recordType: signalBackupRecordType, success: success, failure: failure) } @objc public class func saveFileToCloud(fileUrl: URL, recordName: String, recordType: String, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { let recordID = CKRecordID(recordName: recordName) let record = CKRecord(recordType: recordType, recordID: recordID) let asset = CKAsset(fileURL: fileUrl) record[payloadKey] = asset saveRecordToCloud(record: record, success: success, failure: failure) } @objc public class func saveRecordToCloud(record: CKRecord, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { saveRecordToCloud(record: record, remainingRetries: maxRetries, success: success, failure: failure) } private class func saveRecordToCloud(record: CKRecord, remainingRetries: Int, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { database().save(record) { (_, error) in let outcome = outcomeForCloudKitError(error: error, remainingRetries: remainingRetries, label: "Save Record") switch outcome { case .success: let recordName = record.recordID.recordName success(recordName) case .failureDoNotRetry(let outcomeError): failure(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { saveRecordToCloud(record: record, remainingRetries: remainingRetries - 1, success: success, failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { saveRecordToCloud(record: record, remainingRetries: remainingRetries - 1, success: success, failure: failure) } case .unknownItem: owsFail("\(self.logTag) unexpected CloudKit response.") failure(invalidServiceResponseError()) } } } // Compare: // * An "upsert" creates a new record if none exists and // or updates if there is an existing record. // * A "save once" creates a new record if none exists and // does nothing if there is an existing record. @objc public class func upsertFileToCloud(fileUrl: URL, recordName: String, recordType: String, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { checkForFileInCloud(recordName: recordName, remainingRetries: maxRetries, success: { (record) in if let record = record { // Record found, updating existing record. let asset = CKAsset(fileURL: fileUrl) record[payloadKey] = asset saveRecordToCloud(record: record, success: success, failure: failure) } else { // No record found, saving new record. saveFileToCloud(fileUrl: fileUrl, recordName: recordName, recordType: recordType, success: success, failure: failure) } }, failure: failure) } // Compare: // * An "upsert" creates a new record if none exists and // or updates if there is an existing record. // * A "save once" creates a new record if none exists and // does nothing if there is an existing record. @objc public class func saveFileOnceToCloud(recordName: String, recordType: String, fileUrlBlock: @escaping (()) -> URL?, success: @escaping (String) -> Void, failure: @escaping (Error) -> Void) { checkForFileInCloud(recordName: recordName, remainingRetries: maxRetries, success: { (record) in if record != nil { // Record found, skipping save. success(recordName) } else { // No record found, saving new record. guard let fileUrl = fileUrlBlock(()) else { Logger.error("\(self.logTag) error preparing file for upload.") failure(OWSErrorWithCodeDescription(.exportBackupError, NSLocalizedString("BACKUP_EXPORT_ERROR_SAVE_FILE_TO_CLOUD_FAILED", comment: "Error indicating the backup export failed to save a file to the cloud."))) return } saveFileToCloud(fileUrl: fileUrl, recordName: recordName, recordType: recordType, success: success, failure: failure) } }, failure: failure) } // MARK: - Delete @objc public class func deleteRecordsFromCloud(recordNames: [String], success: @escaping (()) -> Void, failure: @escaping (Error) -> Void) { deleteRecordsFromCloud(recordNames: recordNames, remainingRetries: maxRetries, success: success, failure: failure) } private class func deleteRecordsFromCloud(recordNames: [String], remainingRetries: Int, success: @escaping (()) -> Void, failure: @escaping (Error) -> Void) { let recordIDs = recordNames.map { CKRecordID(recordName: $0) } let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs) deleteOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in let outcome = outcomeForCloudKitError(error: error, remainingRetries: remainingRetries, label: "Delete Records") switch outcome { case .success: success(()) case .failureDoNotRetry(let outcomeError): failure(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { deleteRecordsFromCloud(recordNames: recordNames, remainingRetries: remainingRetries - 1, success: success, failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { deleteRecordsFromCloud(recordNames: recordNames, remainingRetries: remainingRetries - 1, success: success, failure: failure) } case .unknownItem: owsFail("\(self.logTag) unexpected CloudKit response.") failure(invalidServiceResponseError()) } } database().add(deleteOperation) } // MARK: - Exists? private class func checkForFileInCloud(recordName: String, remainingRetries: Int, success: @escaping (CKRecord?) -> Void, failure: @escaping (Error) -> Void) { let recordId = CKRecordID(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 let outcome = outcomeForCloudKitError(error: error, remainingRetries: remainingRetries, label: "Check for Record") switch outcome { case .success: guard let record = record else { owsFail("\(self.logTag) missing fetching record.") failure(invalidServiceResponseError()) return } // Record found. success(record) case .failureDoNotRetry(let outcomeError): failure(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { checkForFileInCloud(recordName: recordName, remainingRetries: remainingRetries - 1, success: success, failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { checkForFileInCloud(recordName: recordName, remainingRetries: remainingRetries - 1, success: success, failure: failure) } case .unknownItem: // Record not found. success(nil) } } database().add(fetchOperation) } @objc public class func checkForManifestInCloud(success: @escaping (Bool) -> Void, failure: @escaping (Error) -> Void) { checkForFileInCloud(recordName: manifestRecordName, remainingRetries: maxRetries, success: { (record) in success(record != nil) }, failure: failure) } @objc public class func fetchAllRecordNames(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(query: query, previousRecordNames: [String](), cursor: nil, remainingRetries: maxRetries, success: success, failure: failure) } private class func fetchAllRecordNamesStep(query: CKQuery, previousRecordNames: [String], cursor: CKQueryCursor?, remainingRetries: Int, success: @escaping ([String]) -> Void, failure: @escaping (Error) -> Void) { var allRecordNames = previousRecordNames 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) allRecordNames.append(record.recordID.recordName) } queryOperation.queryCompletionBlock = { (cursor, error) in let outcome = outcomeForCloudKitError(error: error, remainingRetries: remainingRetries, label: "Fetch All Records") switch outcome { case .success: if let cursor = cursor { Logger.verbose("\(self.logTag) fetching more record names \(allRecordNames.count).") // There are more pages of results, continue fetching. fetchAllRecordNamesStep(query: query, previousRecordNames: allRecordNames, cursor: cursor, remainingRetries: maxRetries, success: success, failure: failure) return } Logger.info("\(self.logTag) fetched \(allRecordNames.count) record names.") success(allRecordNames) case .failureDoNotRetry(let outcomeError): failure(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { fetchAllRecordNamesStep(query: query, previousRecordNames: allRecordNames, cursor: cursor, remainingRetries: remainingRetries - 1, success: success, failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { fetchAllRecordNamesStep(query: query, previousRecordNames: allRecordNames, cursor: cursor, remainingRetries: remainingRetries - 1, success: success, failure: failure) } case .unknownItem: owsFail("\(self.logTag) unexpected CloudKit response.") failure(invalidServiceResponseError()) } } database().add(queryOperation) } // MARK: - Download @objc public class func downloadManifestFromCloud( success: @escaping (Data) -> Void, failure: @escaping (Error) -> Void) { downloadDataFromCloud(recordName: manifestRecordName, success: success, failure: failure) } @objc public class func downloadDataFromCloud(recordName: String, success: @escaping (Data) -> Void, failure: @escaping (Error) -> Void) { downloadFromCloud(recordName: recordName, remainingRetries: maxRetries, success: { (asset) in DispatchQueue.global().async { do { let data = try Data(contentsOf: asset.fileURL) success(data) } catch { Logger.error("\(self.logTag) couldn't load asset file: \(error).") failure(invalidServiceResponseError()) } } }, failure: failure) } @objc public class func downloadFileFromCloud(recordName: String, toFileUrl: URL, success: @escaping (()) -> Void, failure: @escaping (Error) -> Void) { downloadFromCloud(recordName: recordName, remainingRetries: maxRetries, success: { (asset) in DispatchQueue.global().async { do { try FileManager.default.copyItem(at: asset.fileURL, to: toFileUrl) success(()) } catch { Logger.error("\(self.logTag) couldn't copy asset file: \(error).") failure(invalidServiceResponseError()) } } }, failure: failure) } // 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. private class func downloadFromCloud(recordName: String, remainingRetries: Int, success: @escaping (CKAsset) -> Void, failure: @escaping (Error) -> Void) { let recordId = CKRecordID(recordName: recordName) let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ]) // Download all keys for this record. fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in let outcome = outcomeForCloudKitError(error: error, remainingRetries: remainingRetries, label: "Download Record") switch outcome { case .success: guard let record = record else { Logger.error("\(self.logTag) missing fetching record.") failure(invalidServiceResponseError()) return } guard let asset = record[payloadKey] as? CKAsset else { Logger.error("\(self.logTag) record missing payload.") failure(invalidServiceResponseError()) return } success(asset) case .failureDoNotRetry(let outcomeError): failure(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { downloadFromCloud(recordName: recordName, remainingRetries: remainingRetries - 1, success: success, failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { downloadFromCloud(recordName: recordName, remainingRetries: remainingRetries - 1, success: success, failure: failure) } case .unknownItem: Logger.error("\(self.logTag) missing fetching record.") failure(invalidServiceResponseError()) } } database().add(fetchOperation) } // MARK: - Access @objc public class func checkCloudKitAccess(completion: @escaping (Bool) -> Void) { CKContainer.default().accountStatus(completionHandler: { (accountStatus, error) in DispatchQueue.main.async { switch accountStatus { case .couldNotDetermine: Logger.error("\(self.logTag) could not determine CloudKit account status:\(String(describing: error)).") OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's CloudKit account status")) completion(false) case .noAccount: Logger.error("\(self.logTag) no CloudKit account.") OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_NO_ACCOUNT", comment: "Error indicating that user does not have an iCloud account.")) completion(false) case .restricted: Logger.error("\(self.logTag) restricted CloudKit account.") OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_RESTRICTED", comment: "Error indicating that the app was prevented from accessing the user's CloudKit account.")) completion(false) case .available: completion(true) } } }) } // MARK: - Retry private enum APIOutcome { case success case failureDoNotRetry(error:Error) case failureRetryAfterDelay(retryDelay: TimeInterval) case failureRetryWithoutDelay // This only applies to fetches. case unknownItem } private class func outcomeForCloudKitError(error: Error?, remainingRetries: Int, label: String) -> APIOutcome { if let error = error as? CKError { if error.code == CKError.unknownItem { // This is not always an error for our purposes. Logger.verbose("\(self.logTag) \(label) unknown item.") return .unknownItem } Logger.error("\(self.logTag) \(label) failed: \(error)") if remainingRetries < 1 { Logger.verbose("\(self.logTag) \(label) no more retries.") return .failureDoNotRetry(error:error) } if #available(iOS 11, *) { if error.code == CKError.serverResponseLost { Logger.verbose("\(self.logTag) \(label) retry without delay.") return .failureRetryWithoutDelay } } switch error { case CKError.requestRateLimited, CKError.serviceUnavailable, CKError.zoneBusy: let retryDelay = error.retryAfterSeconds ?? 3.0 Logger.verbose("\(self.logTag) \(label) retry with delay: \(retryDelay).") return .failureRetryAfterDelay(retryDelay:retryDelay) case CKError.networkFailure: Logger.verbose("\(self.logTag) \(label) retry without delay.") return .failureRetryWithoutDelay default: Logger.verbose("\(self.logTag) \(label) unknown CKError.") return .failureDoNotRetry(error:error) } } else if let error = error { Logger.error("\(self.logTag) \(label) failed: \(error)") if remainingRetries < 1 { Logger.verbose("\(self.logTag) \(label) no more retries.") return .failureDoNotRetry(error:error) } Logger.verbose("\(self.logTag) \(label) unknown error.") return .failureDoNotRetry(error:error) } else { Logger.info("\(self.logTag) \(label) succeeded.") return .success } } }