session-ios/Signal/src/util/OWSBackupAPI.swift

614 lines
29 KiB
Swift
Raw Normal View History

2018-03-06 14:29:25 +01:00
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalServiceKit
import CloudKit
@objc public class OWSBackupAPI: NSObject {
// If we change the record types, we need to ensure indices
// are configured properly in the CloudKit dashboard.
static let signalBackupRecordType = "signalBackup"
static let manifestRecordName = "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 recordIdForTest() -> String {
2018-03-06 14:29:25 +01:00
return "test-\(NSUUID().uuidString)"
}
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-03-06 14:36:05 +01:00
public class func saveTestFileToCloud(fileUrl: URL,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-06 14:29:25 +01:00
saveFileToCloud(fileUrl: fileUrl,
recordName: NSUUID().uuidString,
recordType: signalBackupRecordType,
success: success,
failure: failure)
}
2018-03-06 19:58:06 +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
2018-03-06 19:58:06 +01:00
public class func saveEphemeralDatabaseFileToCloud(fileUrl: URL,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
saveFileToCloud(fileUrl: fileUrl,
2018-03-12 20:10:37 +01:00
recordName: "ephemeralFile-\(NSUUID().uuidString)",
2018-03-13 18:05:51 +01:00
recordType: signalBackupRecordType,
success: success,
failure: failure)
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 savePersistentFileOnceToCloud(fileId: String,
2018-03-13 18:05:51 +01:00
fileUrlBlock: @escaping (Swift.Void) -> URL?,
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-06 19:58:06 +01:00
saveFileOnceToCloud(recordName: "persistentFile-\(fileId)",
recordType: signalBackupRecordType,
fileUrlBlock: fileUrlBlock,
success: success,
failure: failure)
2018-03-06 14:29:25 +01:00
}
@objc
public class func upsertManifestFileToCloud(fileUrl: URL,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
// We want to use a well-known record id and type for manifest files.
upsertFileToCloud(fileUrl: fileUrl,
2018-03-06 19:58:06 +01:00
recordName: manifestRecordName,
recordType: signalBackupRecordType,
2018-03-06 19:58:06 +01:00
success: success,
failure: failure)
}
2018-03-06 14:29:25 +01:00
@objc
2018-03-06 14:36:05 +01:00
public class func saveFileToCloud(fileUrl: URL,
recordName: String,
2018-03-06 14:36:05 +01:00
recordType: String,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
let recordID = CKRecordID(recordName: recordName)
2018-03-06 14:29:25 +01:00
let record = CKRecord(recordType: recordType, recordID: recordID)
2018-03-06 14:36:05 +01:00
let asset = CKAsset(fileURL: fileUrl)
record[payloadKey] = asset
2018-03-06 14:36:05 +01:00
saveRecordToCloud(record: record,
success: success,
failure: failure)
2018-03-06 14:36:05 +01:00
}
@objc
public class func saveRecordToCloud(record: CKRecord,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
saveRecordToCloud(record: record,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-13 18:05:51 +01:00
success: success,
failure: failure)
2018-03-06 14:29:25 +01:00
}
2018-03-13 18:05:51 +01:00
private class func saveRecordToCloud(record: CKRecord,
remainingRetries: Int,
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-13 18:05:51 +01:00
database().save(record) {
(_, error) in
2018-03-13 18:05:51 +01:00
let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Save Record")
switch response {
case .success:
let recordName = record.recordID.recordName
success(recordName)
case .failureDoNotRetry(let responseError):
failure(responseError)
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)
}
2018-03-13 18:39:00 +01:00
case .unknownItem:
owsFail("\(self.logTag) unexpected CloudKit response.")
failure(invalidServiceResponseError())
}
}
}
2018-03-08 14:31:35 +01:00
// 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,
2018-03-13 18:05:51 +01:00
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 14:31:35 +01:00
checkForFileInCloud(recordName: recordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-08 14:31:35 +01:00
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,
2018-03-13 18:05:51 +01:00
fileUrlBlock: @escaping (Swift.Void) -> URL?,
success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 14:31:35 +01:00
checkForFileInCloud(recordName: recordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-08 14:31:35 +01:00
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 a backup export failed to save a file to the cloud.")))
return
}
saveFileToCloud(fileUrl: fileUrl,
recordName: recordName,
recordType: recordType,
success: success,
failure: failure)
}
},
failure: failure)
}
2018-03-13 18:05:51 +01:00
// MARK: - Delete
@objc
public class func deleteRecordFromCloud(recordName: String,
success: @escaping (Swift.Void) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
deleteRecordFromCloud(recordName: recordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-13 18:05:51 +01:00
success: success,
failure: failure)
}
private class func deleteRecordFromCloud(recordName: String,
remainingRetries: Int,
success: @escaping (Swift.Void) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
let recordID = CKRecordID(recordName: recordName)
database().delete(withRecordID: recordID) {
2018-03-13 18:39:00 +01:00
(_, error) in
2018-03-13 18:05:51 +01:00
let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Delete Record")
switch response {
case .success:
success()
case .failureDoNotRetry(let responseError):
failure(responseError)
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
deleteRecordFromCloud(recordName: recordName,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
deleteRecordFromCloud(recordName: recordName,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
}
2018-03-13 18:39:00 +01:00
case .unknownItem:
owsFail("\(self.logTag) unexpected CloudKit response.")
failure(invalidServiceResponseError())
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-03-13 18:39:00 +01:00
remainingRetries: Int,
2018-03-13 18:05:51 +01:00
success: @escaping (CKRecord?) -> Swift.Void,
failure: @escaping (Error) -> Swift.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
2018-03-13 18:39:00 +01:00
let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Check for Record")
switch response {
case .success:
guard let record = record else {
owsFail("\(self.logTag) missing fetching record.")
failure(invalidServiceResponseError())
return
}
2018-03-13 18:39:00 +01:00
// Record found.
success(record)
case .failureDoNotRetry(let responseError):
failure(responseError)
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)
}
2018-03-06 19:58:06 +01:00
}
2018-03-13 18:05:51 +01:00
database().add(fetchOperation)
2018-03-06 19:58:06 +01:00
}
@objc
2018-03-13 18:05:51 +01:00
public class func checkForManifestInCloud(success: @escaping (Bool) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 14:31:35 +01:00
checkForFileInCloud(recordName: manifestRecordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-08 14:31:35 +01:00
success: { (record) in
success(record != nil)
},
failure: failure)
}
@objc
2018-03-13 18:05:51 +01:00
public class func fetchAllRecordNames(success: @escaping ([String]) -> Swift.Void,
failure: @escaping (Error) -> Swift.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,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
success: success,
failure: failure)
}
private class func fetchAllRecordNamesStep(query: CKQuery,
previousRecordNames: [String],
cursor: CKQueryCursor?,
2018-03-13 18:39:00 +01:00
remainingRetries: Int,
2018-03-13 18:05:51 +01:00
success: @escaping ([String]) -> Swift.Void,
failure: @escaping (Error) -> Swift.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)
allRecordNames.append(record.recordID.recordName)
}
queryOperation.queryCompletionBlock = { (cursor, error) in
2018-03-13 18:39:00 +01:00
let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Fetch All Records")
switch response {
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 responseError):
failure(responseError)
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())
}
}
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
public class func downloadManifestFromCloud(
2018-03-13 18:05:51 +01:00
success: @escaping (Data) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 19:38:42 +01:00
downloadDataFromCloud(recordName: manifestRecordName,
2018-03-13 18:05:51 +01:00
success: success,
failure: failure)
2018-03-08 19:38:42 +01:00
}
@objc
public class func downloadDataFromCloud(recordName: String,
2018-03-13 18:05:51 +01:00
success: @escaping (Data) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 19:38:42 +01:00
downloadFromCloud(recordName: recordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-08 19:38:42 +01:00
success: { (asset) in
DispatchQueue.global().async {
do {
let data = try Data(contentsOf: asset.fileURL)
success(data)
} catch {
2018-03-12 20:10:37 +01:00
Logger.error("\(self.logTag) couldn't load asset file: \(error).")
2018-03-13 18:39:00 +01:00
failure(invalidServiceResponseError())
2018-03-08 19:38:42 +01:00
}
}
},
failure: failure)
}
@objc
public class func downloadFileFromCloud(recordName: String,
toFileUrl: URL,
2018-03-13 18:05:51 +01:00
success: @escaping (Swift.Void) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 19:38:42 +01:00
downloadFromCloud(recordName: recordName,
2018-03-13 18:39:00 +01:00
remainingRetries: maxRetries,
2018-03-08 19:38:42 +01:00
success: { (asset) in
DispatchQueue.global().async {
do {
2018-03-12 20:10:37 +01:00
try FileManager.default.copyItem(at: asset.fileURL, to: toFileUrl)
2018-03-08 19:38:42 +01:00
success()
} catch {
2018-03-12 20:10:37 +01:00
Logger.error("\(self.logTag) couldn't copy asset file: \(error).")
2018-03-13 18:39:00 +01:00
failure(invalidServiceResponseError())
2018-03-08 19:38:42 +01:00
}
}
},
failure: failure)
}
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-03-13 18:39:00 +01:00
remainingRetries: Int,
2018-03-13 18:05:51 +01:00
success: @escaping (CKAsset) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) {
2018-03-08 19:38:42 +01:00
let recordId = CKRecordID(recordName: recordName)
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
let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Download Record")
switch response {
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 responseError):
failure(responseError)
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:
2018-03-08 19:38:42 +01:00
Logger.error("\(self.logTag) missing fetching record.")
2018-03-13 18:39:00 +01:00
failure(invalidServiceResponseError())
2018-03-08 19:38:42 +01:00
}
}
2018-03-13 18:05:51 +01:00
database().add(fetchOperation)
2018-03-08 19:38:42 +01:00
}
2018-03-13 18:05:51 +01:00
// MARK: - Access
2018-03-06 14:29:25 +01:00
@objc
2018-03-13 18:05:51 +01:00
public class func checkCloudKitAccess(completion: @escaping (Bool) -> Swift.Void) {
2018-03-06 14:29:25 +01:00
CKContainer.default().accountStatus(completionHandler: { (accountStatus, error) in
DispatchQueue.main.async {
2018-03-06 14:36:05 +01:00
switch accountStatus {
case .couldNotDetermine:
Logger.error("\(self.logTag) could not determine CloudKit account status:\(String(describing: error)).")
2018-03-12 21:49:57 +01:00
OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's CloudKit account status"))
2018-03-06 14:36:05 +01:00
completion(false)
case .noAccount:
Logger.error("\(self.logTag) no CloudKit account.")
2018-03-12 21:49:57 +01:00
OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_NO_ACCOUNT", comment: "Error indicating that user does not have an iCloud account."))
2018-03-06 14:36:05 +01:00
completion(false)
case .restricted:
Logger.error("\(self.logTag) restricted CloudKit account.")
2018-03-12 21:49:57 +01:00
OWSAlerts.showErrorAlert(message: NSLocalizedString("CLOUDKIT_STATUS_RESTRICTED", comment: "Error indicating that the app was prevented from accessing the user's CloudKit account."))
2018-03-06 14:36:05 +01:00
completion(false)
case .available:
completion(true)
}
2018-03-06 14:29:25 +01:00
}
})
}
2018-03-13 18:05:51 +01:00
// MARK: - Retry
private enum CKErrorResponse {
case success
case failureDoNotRetry(error:Error)
case failureRetryAfterDelay(retryDelay: Double)
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
}
private class func responseForCloudKitError(error: Error?,
remainingRetries: Int,
label: String) -> CKErrorResponse {
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.
Logger.verbose("\(self.logTag) \(label) unknown item.")
return .unknownItem
}
2018-03-13 18:05:51 +01:00
Logger.error("\(self.logTag) \(label) failed: \(error)")
2018-03-13 18:39:00 +01:00
2018-03-13 18:05:51 +01:00
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
}
}
2018-03-06 14:29:25 +01:00
}