diff --git a/Signal/src/ViewControllers/AppSettings/OWSBackupSettingsViewController.m b/Signal/src/ViewControllers/AppSettings/OWSBackupSettingsViewController.m index a49910188..0dae62597 100644 --- a/Signal/src/ViewControllers/AppSettings/OWSBackupSettingsViewController.m +++ b/Signal/src/ViewControllers/AppSettings/OWSBackupSettingsViewController.m @@ -6,6 +6,7 @@ #import "OWSBackup.h" #import "Signal-Swift.h" #import "ThreadUtil.h" +#import #import #import #import @@ -18,12 +19,25 @@ NS_ASSUME_NONNULL_BEGIN @interface OWSBackupSettingsViewController () +@property (nonatomic, nullable) NSError *iCloudError; + @end #pragma mark - @implementation OWSBackupSettingsViewController +#pragma mark - Dependencies + +- (OWSBackup *)backup +{ + OWSAssertDebug(AppEnvironment.shared.backup); + + return AppEnvironment.shared.backup; +} + +#pragma mark - + - (void)viewDidLoad { [super viewDidLoad]; @@ -34,6 +48,10 @@ NS_ASSUME_NONNULL_BEGIN selector:@selector(backupStateDidChange:) name:NSNotificationNameBackupStateDidChange object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:OWSApplicationDidBecomeActiveNotification + object:nil]; [self updateTableContents]; } @@ -46,7 +64,27 @@ NS_ASSUME_NONNULL_BEGIN - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; + [self updateTableContents]; + [self updateICloudStatus]; +} + +- (void)updateICloudStatus +{ + __weak OWSBackupSettingsViewController *weakSelf = self; + [[self.backup ensureCloudKitAccess] + .then(^{ + OWSAssertIsOnMainThread(); + + weakSelf.iCloudError = nil; + [weakSelf updateTableContents]; + }) + .catch(^(NSError *error) { + OWSAssertIsOnMainThread(); + + weakSelf.iCloudError = error; + [weakSelf updateTableContents]; + }) retainUntilComplete]; } #pragma mark - Table Contents @@ -57,6 +95,20 @@ NS_ASSUME_NONNULL_BEGIN BOOL isBackupEnabled = [OWSBackup.sharedManager isBackupEnabled]; + if (self.iCloudError) { + OWSTableSection *iCloudSection = [OWSTableSection new]; + iCloudSection.headerTitle = NSLocalizedString( + @"SETTINGS_BACKUP_ICLOUD_STATUS", @"Label for iCloud status row in the in the backup settings view."); + [iCloudSection + addItem:[OWSTableItem + longDisclosureItemWithText:[OWSBackupAPI errorMessageForCloudKitAccessError:self.iCloudError] + actionBlock:^{ + [[UIApplication sharedApplication] + openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]]; + }]]; + [contents addSection:iCloudSection]; + } + // TODO: This UI is temporary. // Enabling backup will involve entering and registering a PIN. OWSTableSection *enableSection = [OWSTableSection new]; @@ -77,7 +129,7 @@ NS_ASSUME_NONNULL_BEGIN [progressSection addItem:[OWSTableItem labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_STATUS", - @"Label for status row in the in the backup settings view.") + @"Label for backup status row in the in the backup settings view.") accessoryText:NSStringForBackupExportState(OWSBackup.sharedManager.backupExportState)]]; if (OWSBackup.sharedManager.backupExportState == OWSBackupState_InProgress) { if (OWSBackup.sharedManager.backupExportDescription) { @@ -141,9 +193,18 @@ NS_ASSUME_NONNULL_BEGIN - (void)backupStateDidChange:(NSNotification *)notification { + OWSAssertIsOnMainThread(); + [self updateTableContents]; } +- (void)applicationDidBecomeActive:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + [self updateICloudStatus]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m b/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m index 74e775cce..880ec8bf8 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m @@ -6,6 +6,7 @@ #import "OWSBackup.h" #import "OWSTableViewController.h" #import "Signal-Swift.h" +#import #import @import CloudKit; @@ -23,6 +24,13 @@ NS_ASSUME_NONNULL_BEGIN return SSKEnvironment.shared.tsAccountManager; } ++ (OWSBackup *)backup +{ + OWSAssertDebug(AppEnvironment.shared.backup); + + return AppEnvironment.shared.backup; +} + #pragma mark - Factory Methods - (NSString *)name @@ -80,18 +88,10 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertDebug(success); NSString *recipientId = self.tsAccountManager.localNumber; - [OWSBackupAPI checkCloudKitAccessWithCompletion:^(BOOL hasAccess) { - if (hasAccess) { - [OWSBackupAPI saveTestFileToCloudWithRecipientId:recipientId - fileUrl:[NSURL fileURLWithPath:filePath] - success:^(NSString *recordName) { - // Do nothing, the API method will log for us. - } - failure:^(NSError *error){ - // Do nothing, the API method will log for us. - }]; - } - }]; + [[self.backup ensureCloudKitAccess].then(^{ + return + [OWSBackupAPI saveTestFileToCloudObjcWithRecipientId:recipientId fileUrl:[NSURL fileURLWithPath:filePath]]; + }) retainUntilComplete]; } + (void)checkForBackup diff --git a/Signal/src/ViewControllers/ProfileViewController.m b/Signal/src/ViewControllers/ProfileViewController.m index 6b2a3cf5a..0bbfa9e1d 100644 --- a/Signal/src/ViewControllers/ProfileViewController.m +++ b/Signal/src/ViewControllers/ProfileViewController.m @@ -471,6 +471,8 @@ NSString *const kProfileView_LastPresentedDate = @"kProfileView_LastPresentedDat __weak ProfileViewController *weakSelf = self; [OWSBackup.sharedManager checkCanImportBackup:^(BOOL value) { + OWSAssertIsOnMainThread(); + OWSLogInfo(@"has backup available for import? %d", value); if (value) { diff --git a/Signal/src/util/Backup/OWSBackup.h b/Signal/src/util/Backup/OWSBackup.h index 6d243b3e8..38211f101 100644 --- a/Signal/src/util/Backup/OWSBackup.h +++ b/Signal/src/util/Backup/OWSBackup.h @@ -26,6 +26,9 @@ NSString *NSStringForBackupImportState(OWSBackupState state); NSArray *MiscCollectionsToBackup(void); +NSError *OWSBackupErrorWithDescription(NSString *description); + +@class AnyPromise; @class OWSBackupIO; @class TSAttachmentPointer; @class TSThread; @@ -71,6 +74,10 @@ NSArray *MiscCollectionsToBackup(void); - (void)allRecipientIdsWithManifestsInCloud:(OWSBackupStringListBlock)success failure:(OWSBackupErrorBlock)failure; +- (AnyPromise *)ensureCloudKitAccess; + +- (AnyPromise *)checkCanExportBackup; + - (void)checkCanImportBackup:(OWSBackupBoolBlock)success failure:(OWSBackupErrorBlock)failure; // TODO: After a successful import, we should enable backup and diff --git a/Signal/src/util/Backup/OWSBackup.m b/Signal/src/util/Backup/OWSBackup.m index ccce6fcb0..6e37a79f6 100644 --- a/Signal/src/util/Backup/OWSBackup.m +++ b/Signal/src/util/Backup/OWSBackup.m @@ -7,10 +7,13 @@ #import "OWSBackupIO.h" #import "OWSBackupImportJob.h" #import "Signal-Swift.h" +#import #import #import #import +@import CloudKit; + NS_ASSUME_NONNULL_BEGIN NSString *const NSNotificationNameBackupStateDidChange = @"NSNotificationNameBackupStateDidChange"; @@ -62,6 +65,17 @@ NSArray *MiscCollectionsToBackup(void) ]; } +typedef NS_ENUM(NSInteger, OWSBackupErrorCode) { + OWSBackupErrorCodeAssertionFailure = 0, +}; + +NSError *OWSBackupErrorWithDescription(NSString *description) +{ + return [NSError errorWithDomain:@"OWSBackupErrorDomain" + code:OWSBackupErrorCodeAssertionFailure + userInfo:@{ NSLocalizedDescriptionKey : description }]; +} + // TODO: Observe Reachability. @interface OWSBackup () @@ -131,6 +145,10 @@ NSArray *MiscCollectionsToBackup(void) selector:@selector(registrationStateDidChange) name:RegistrationStateDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(ckAccountChanged) + name:CKAccountChangedNotification + object:nil]; // We want to start a backup if necessary on app launch, but app launch is a // busy time and it's important to remain responsive, so wait a few seconds before @@ -199,7 +217,7 @@ NSArray *MiscCollectionsToBackup(void) _backupExportState = OWSBackupState_InProgress; self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId]; - [self.backupExportJob startAsync]; + [self.backupExportJob start]; [self postDidChangeNotification]; } @@ -351,7 +369,7 @@ NSArray *MiscCollectionsToBackup(void) self.backupExportJob = nil; } else if (self.shouldHaveBackupExport && !self.backupExportJob) { self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId]; - [self.backupExportJob startAsync]; + [self.backupExportJob start]; } // Update the state flag. @@ -403,6 +421,40 @@ NSArray *MiscCollectionsToBackup(void) }]; } +- (AnyPromise *)checkCanExportBackup +{ + return [self ensureCloudKitAccess]; +} + +- (AnyPromise *)ensureCloudKitAccess +{ + OWSAssertIsOnMainThread(); + + OWSLogInfo(@""); + + AnyPromise * (^failWithUnexpectedError)(void) = ^{ + NSError *error = [NSError errorWithDomain:OWSBackupErrorDomain + code:1 + userInfo:@{ + NSLocalizedDescriptionKey : NSLocalizedString(@"BACKUP_UNEXPECTED_ERROR", + @"Error shown when backup fails due to an unexpected error.") + }]; + return [AnyPromise promiseWithValue:error]; + }; + + if (!self.tsAccountManager.isRegisteredAndReady) { + OWSLogError(@"Can't backup; not registered and ready."); + return failWithUnexpectedError(); + } + NSString *_Nullable recipientId = self.tsAccountManager.localNumber; + if (recipientId.length < 1) { + OWSFailDebug(@"Can't backup; missing recipientId."); + return failWithUnexpectedError(); + } + + return [OWSBackupAPI ensureCloudKitAccessObjc]; +} + - (void)checkCanImportBackup:(OWSBackupBoolBlock)success failure:(OWSBackupErrorBlock)failure { OWSAssertIsOnMainThread(); @@ -432,17 +484,16 @@ NSArray *MiscCollectionsToBackup(void) return failWithUnexpectedError(); } - [OWSBackupAPI checkForManifestInCloudWithRecipientId:recipientId - success:^(BOOL value) { - dispatch_async(dispatch_get_main_queue(), ^{ - success(value); - }); - } - failure:^(NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ + [[OWSBackupAPI ensureCloudKitAccessObjc] + .thenInBackground(^{ + return [OWSBackupAPI checkForManifestInCloudObjcWithRecipientId:recipientId]; + }) + .then(^(NSNumber *value) { + success(value.boolValue); + }) + .catch(^(NSError *error) { failure(error); - }); - }]; + }) retainUntilComplete]; } - (void)tryToImportBackup @@ -502,6 +553,15 @@ NSArray *MiscCollectionsToBackup(void) [self postDidChangeNotification]; } +- (void)ckAccountChanged +{ + OWSAssertIsOnMainThread(); + + [self ensureBackupExportState]; + + [self postDidChangeNotification]; +} + #pragma mark - OWSBackupJobDelegate // We use a delegate method to avoid storing this key in memory. diff --git a/Signal/src/util/Backup/OWSBackupAPI.swift b/Signal/src/util/Backup/OWSBackupAPI.swift index 73531cd44..93ecdb806 100644 --- a/Signal/src/util/Backup/OWSBackupAPI.swift +++ b/Signal/src/util/Backup/OWSBackupAPI.swift @@ -5,6 +5,7 @@ import Foundation import SignalServiceKit import CloudKit +import PromiseKit // We don't worry about atomic writes. Each backup export // will diff against last successful backup. @@ -42,16 +43,18 @@ import CloudKit // MARK: - Upload @objc + public class func saveTestFileToCloudObjc(recipientId: String, + fileUrl: URL) -> AnyPromise { + return AnyPromise(saveTestFileToCloud(recipientId: recipientId, + fileUrl: fileUrl)) + } + public class func saveTestFileToCloud(recipientId: String, - fileUrl: URL, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + fileUrl: URL) -> Promise { let recordName = "\(recordNamePrefix(forRecipientId: recipientId))test-\(NSUUID().uuidString)" - saveFileToCloud(fileUrl: fileUrl, - recordName: recordName, - recordType: signalBackupRecordType, - success: success, - failure: failure) + return saveFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: signalBackupRecordType) } // "Ephemeral" files are specific to this backup export and will always need to @@ -59,16 +62,18 @@ import CloudKit // We wouldn't want to overwrite previous images until the entire backup export is // complete. @objc + public class func saveEphemeralDatabaseFileToCloudObjc(recipientId: String, + fileUrl: URL) -> AnyPromise { + return AnyPromise(saveEphemeralDatabaseFileToCloud(recipientId: recipientId, + fileUrl: fileUrl)) + } + public class func saveEphemeralDatabaseFileToCloud(recipientId: String, - fileUrl: URL, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + fileUrl: URL) -> Promise { let recordName = "\(recordNamePrefix(forRecipientId: recipientId))ephemeralFile-\(NSUUID().uuidString)" - saveFileToCloud(fileUrl: fileUrl, - recordName: recordName, - recordType: signalBackupRecordType, - success: success, - failure: failure) + return saveFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: signalBackupRecordType) } // "Persistent" files may be shared between backup export; they should only be saved @@ -124,9 +129,9 @@ import CloudKit let firstRange = match.range(at: 1) guard firstRange.location == 0, firstRange.length > 0 else { - // Match must be at start of string and non-empty. - Logger.warn("invalid match: \(recordName) \(firstRange)") - continue + // Match must be at start of string and non-empty. + Logger.warn("invalid match: \(recordName) \(firstRange)") + continue } let recipientId = (recordName as NSString).substring(with: firstRange) as String recipientIds.append(recipientId) @@ -138,104 +143,120 @@ import CloudKit // once. For example, attachment files should only be uploaded once. Subsequent // backups can reuse the same record. @objc + public class func savePersistentFileOnceToCloudObjc(recipientId: String, + fileId: String, + fileUrlBlock: @escaping () -> URL?) -> AnyPromise { + return AnyPromise(savePersistentFileOnceToCloud(recipientId: recipientId, + fileId: fileId, + fileUrlBlock: fileUrlBlock)) + } + public class func savePersistentFileOnceToCloud(recipientId: String, fileId: String, - fileUrlBlock: @escaping () -> URL?, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + fileUrlBlock: @escaping () -> URL?) -> Promise { let recordName = recordNameForPersistentFile(recipientId: recipientId, fileId: fileId) - saveFileOnceToCloud(recordName: recordName, - recordType: signalBackupRecordType, - fileUrlBlock: fileUrlBlock, - success: success, - failure: failure) + return saveFileOnceToCloud(recordName: recordName, + recordType: signalBackupRecordType, + fileUrlBlock: fileUrlBlock) } @objc + public class func upsertManifestFileToCloudObjc(recipientId: String, + fileUrl: URL) -> AnyPromise { + return AnyPromise(upsertManifestFileToCloud(recipientId: recipientId, + fileUrl: fileUrl)) + } + public class func upsertManifestFileToCloud(recipientId: String, - fileUrl: URL, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + fileUrl: URL) -> Promise { // We want to use a well-known record id and type for manifest files. let recordName = recordNameForManifest(recipientId: recipientId) - upsertFileToCloud(fileUrl: fileUrl, - recordName: recordName, - recordType: signalBackupRecordType, - success: success, - failure: failure) + return upsertFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: signalBackupRecordType) } @objc + public class func saveFileToCloudObjc(fileUrl: URL, + recordName: String, + recordType: String) -> AnyPromise { + return AnyPromise(saveFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: recordType)) + } + public class func saveFileToCloud(fileUrl: URL, recordName: String, - recordType: String, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + recordType: String) -> Promise { 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) + return saveRecordToCloud(record: record) } @objc - public class func saveRecordToCloud(record: CKRecord, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { - saveRecordToCloud(record: record, - remainingRetries: maxRetries, - success: success, - failure: failure) + public class func saveRecordToCloudObjc(record: CKRecord) -> AnyPromise { + return AnyPromise(saveRecordToCloud(record: record)) } - private class func saveRecordToCloud(record: CKRecord, - remainingRetries: Int, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { - - let saveOperation = CKModifyRecordsOperation(recordsToSave: [record ], recordIDsToDelete: nil) - saveOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in + public class func saveRecordToCloud(record: CKRecord) -> Promise { + return saveRecordToCloud(record: record, + remainingRetries: maxRetries) + } - 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) + private class func saveRecordToCloud(record: CKRecord, + remainingRetries: Int) -> Promise { + + return Promise { resolver in + let saveOperation = CKModifyRecordsOperation(recordsToSave: [record ], recordIDsToDelete: nil) + saveOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in + + let outcome = outcomeForCloudKitError(error: error, + remainingRetries: remainingRetries, + label: "Save Record") + switch outcome { + case .success: + let recordName = record.recordID.recordName + resolver.fulfill(recordName) + case .failureDoNotRetry(let outcomeError): + resolver.reject(outcomeError) + case .failureRetryAfterDelay(let retryDelay): + DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { + saveRecordToCloud(record: record, + remainingRetries: remainingRetries - 1) + .done { (recordName) in + resolver.fulfill(recordName) + }.catch { (error) in + resolver.reject(error) + }.retainUntilComplete() + }) + case .failureRetryWithoutDelay: + DispatchQueue.global().async { + saveRecordToCloud(record: record, + remainingRetries: remainingRetries - 1) + .done { (recordName) in + resolver.fulfill(recordName) + }.catch { (error) in + resolver.reject(error) + }.retainUntilComplete() + } + case .unknownItem: + owsFailDebug("unexpected CloudKit response.") + resolver.reject(invalidServiceResponseError()) } - case .unknownItem: - owsFailDebug("unexpected CloudKit response.") - failure(invalidServiceResponseError()) } - } - saveOperation.isAtomic = false + saveOperation.isAtomic = false - // These APIs are only available in iOS 9.3 and later. - if #available(iOS 9.3, *) { - saveOperation.isLongLived = true - saveOperation.qualityOfService = .background - } + // 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) + database().add(saveOperation) + } } // Compare: @@ -244,32 +265,33 @@ import CloudKit // * A "save once" creates a new record if none exists and // does nothing if there is an existing record. @objc + public class func upsertFileToCloudObjc(fileUrl: URL, + recordName: String, + recordType: String) -> AnyPromise { + return AnyPromise(upsertFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: recordType)) + } + 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) + recordType: String) -> Promise { + + return checkForFileInCloud(recordName: recordName, + remainingRetries: maxRetries) + .then { (record: CKRecord?) -> Promise in + if let record = record { + // Record found, updating existing record. + let asset = CKAsset(fileURL: fileUrl) + record[payloadKey] = asset + return saveRecordToCloud(record: record) + } + + // No record found, saving new record. + return saveFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: recordType) + } } // Compare: @@ -278,54 +300,55 @@ import CloudKit // * A "save once" creates a new record if none exists and // does nothing if there is an existing record. @objc + public class func saveFileOnceToCloudObjc(recordName: String, + recordType: String, + fileUrlBlock: @escaping () -> URL?) -> AnyPromise { + return AnyPromise(saveFileOnceToCloud(recordName: recordName, + recordType: recordType, + fileUrlBlock: fileUrlBlock)) + } + public class func saveFileOnceToCloud(recordName: String, recordType: String, - fileUrlBlock: @escaping () -> URL?, - success: @escaping (String) -> Void, - failure: @escaping (Error) -> Void) { + fileUrlBlock: @escaping () -> URL?) -> Promise { + + return checkForFileInCloud(recordName: recordName, + remainingRetries: maxRetries) + .then { (record: CKRecord?) -> Promise in + if record != nil { + // Record found, skipping save. + return Promise.value(recordName) + } + // No record found, saving new record. + guard let fileUrl = fileUrlBlock() else { + Logger.error("error preparing file for upload.") + return Promise(error: 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."))) + } - 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("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) + return saveFileToCloud(fileUrl: fileUrl, + recordName: recordName, + recordType: recordType) + } } // MARK: - Delete @objc public class func deleteRecordsFromCloud(recordNames: [String], - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: maxRetries, - success: success, - failure: failure) + remainingRetries: maxRetries, + success: success, + failure: failure) } private class func deleteRecordsFromCloud(recordNames: [String], - remainingRetries: Int, - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { + remainingRetries: Int, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { let recordIDs = recordNames.map { CKRecordID(recordName: $0) } let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs) @@ -342,16 +365,16 @@ import CloudKit case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) + remainingRetries: remainingRetries - 1, + success: success, + failure: failure) }) case .failureRetryWithoutDelay: DispatchQueue.global().async { deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) + remainingRetries: remainingRetries - 1, + success: success, + failure: failure) } case .unknownItem: owsFailDebug("unexpected CloudKit response.") @@ -364,9 +387,10 @@ import CloudKit // MARK: - Exists? private class func checkForFileInCloud(recordName: String, - remainingRetries: Int, - success: @escaping (CKRecord?) -> Void, - failure: @escaping (Error) -> Void) { + remainingRetries: Int) -> Promise { + + let (promise, resolver) = Promise.pending() + 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 @@ -375,53 +399,61 @@ import CloudKit fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Check for Record") + remainingRetries: remainingRetries, + label: "Check for Record") switch outcome { case .success: guard let record = record else { owsFailDebug("missing fetching record.") - failure(invalidServiceResponseError()) + resolver.reject(invalidServiceResponseError()) return } // Record found. - success(record) + resolver.fulfill(record) case .failureDoNotRetry(let outcomeError): - failure(outcomeError) + resolver.reject(outcomeError) case .failureRetryAfterDelay(let retryDelay): DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { checkForFileInCloud(recordName: recordName, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) + remainingRetries: remainingRetries - 1) + .done { (record) in + resolver.fulfill(record) + }.catch { (error) in + resolver.reject(error) + }.retainUntilComplete() }) case .failureRetryWithoutDelay: DispatchQueue.global().async { checkForFileInCloud(recordName: recordName, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) + remainingRetries: remainingRetries - 1) + .done { (record) in + resolver.fulfill(record) + }.catch { (error) in + resolver.reject(error) + }.retainUntilComplete() } case .unknownItem: // Record not found. - success(nil) + resolver.fulfill(nil) } } database().add(fetchOperation) + return promise } @objc - public class func checkForManifestInCloud(recipientId: String, - success: @escaping (Bool) -> Void, - failure: @escaping (Error) -> Void) { + public class func checkForManifestInCloudObjc(recipientId: String) -> AnyPromise { + return AnyPromise(checkForManifestInCloud(recipientId: recipientId)) + } + + public class func checkForManifestInCloud(recipientId: String) -> Promise { let recordName = recordNameForManifest(recipientId: recipientId) - checkForFileInCloud(recordName: recordName, - remainingRetries: maxRetries, - success: { (record) in - success(record != nil) - }, - failure: failure) + return checkForFileInCloud(recordName: recordName, + remainingRetries: maxRetries) + .map { (record) in + return record != nil + } } @objc @@ -499,8 +531,8 @@ import CloudKit queryOperation.queryCompletionBlock = { (cursor, error) in let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Fetch All Records") + remainingRetries: remainingRetries, + label: "Fetch All Records") switch outcome { case .success: if let cursor = cursor { @@ -619,8 +651,8 @@ import CloudKit fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Download Record") + remainingRetries: remainingRetries, + label: "Download Record") switch outcome { case .success: guard let record = record else { @@ -660,28 +692,59 @@ import CloudKit // MARK: - Access + @objc public enum BackupError: Int, Error { + case couldNotDetermineAccountStatus + case noAccount + case restrictedAccountStatus + } + @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("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("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("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) - } + public class func ensureCloudKitAccessObjc() -> AnyPromise { + return AnyPromise(ensureCloudKitAccess()) + } + + public class func ensureCloudKitAccess() -> Promise { + let (promise, resolver) = Promise.pending() + CKContainer.default().accountStatus { (accountStatus, error) in + 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(()) + } + } + 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") + } } // MARK: - Retry @@ -696,8 +759,8 @@ import CloudKit } private class func outcomeForCloudKitError(error: Error?, - remainingRetries: Int, - label: String) -> APIOutcome { + 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. diff --git a/Signal/src/util/Backup/OWSBackupExportJob.h b/Signal/src/util/Backup/OWSBackupExportJob.h index f72f742f0..8fa9b475e 100644 --- a/Signal/src/util/Backup/OWSBackupExportJob.h +++ b/Signal/src/util/Backup/OWSBackupExportJob.h @@ -8,7 +8,7 @@ NS_ASSUME_NONNULL_BEGIN @interface OWSBackupExportJob : OWSBackupJob -- (void)startAsync; +- (void)start; @end diff --git a/Signal/src/util/Backup/OWSBackupExportJob.m b/Signal/src/util/Backup/OWSBackupExportJob.m index 2e1cf1dbc..6ff6ed111 100644 --- a/Signal/src/util/Backup/OWSBackupExportJob.m +++ b/Signal/src/util/Backup/OWSBackupExportJob.m @@ -6,6 +6,7 @@ #import "OWSBackupIO.h" #import "OWSDatabaseMigration.h" #import "Signal-Swift.h" +#import #import #import #import @@ -327,9 +328,16 @@ NS_ASSUME_NONNULL_BEGIN return SSKEnvironment.shared.primaryStorage; } +- (OWSBackup *)backup +{ + OWSAssertDebug(AppEnvironment.shared.backup); + + return AppEnvironment.shared.backup; +} + #pragma mark - -- (void)startAsync +- (void)start { OWSAssertIsOnMainThread(); @@ -339,87 +347,53 @@ NS_ASSUME_NONNULL_BEGIN [self updateProgressWithDescription:nil progress:nil]; - __weak OWSBackupExportJob *weakSelf = self; - [OWSBackupAPI checkCloudKitAccessWithCompletion:^(BOOL hasAccess) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (hasAccess) { - [weakSelf start]; - } else { - [weakSelf failWithErrorDescription: - NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", - @"Error indicating the backup export could not export the user's data.")]; - } - }); - }]; -} - -- (void)start -{ - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CONFIGURATION", - @"Indicates that the backup export is being configured.") - progress:nil]; - - __weak OWSBackupExportJob *weakSelf = self; - [self configureExportWithCompletion:^(BOOL configureExportSuccess) { - if (!configureExportSuccess) { - [self - failWithErrorDescription:NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", - @"Error indicating the backup export could not export the user's data.")]; - return; - } - - if (self.isComplete) { - return; - } - [self fetchAllRecordsWithCompletion:^(BOOL tryToFetchManifestSuccess) { - if (!tryToFetchManifestSuccess) { - [self failWithErrorDescription: - NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", - @"Error indicating the backup export could not export the user's data.")]; - return; - } + [[self.backup ensureCloudKitAccess] + .thenInBackground(^{ + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CONFIGURATION", + @"Indicates that the backup export is being configured.") + progress:nil]; + + return [self configureExport]; + }) + .thenInBackground(^{ + return [self fetchAllRecords]; + }) + .thenInBackground(^{ + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_EXPORT", + @"Indicates that the backup export data is being exported.") + progress:nil]; + + return [self exportDatabase]; + }) + .thenInBackground(^{ + return [self saveToCloud]; + }) + .thenInBackground(^{ + return [self cleanUp]; + }) + .thenInBackground(^{ + [self succeed]; + }) + .catch(^(NSError *error) { + OWSFailDebug(@"Backup export failed with error: %@.", error); - if (self.isComplete) { - return; - } - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_EXPORT", - @"Indicates that the backup export data is being exported.") - progress:nil]; - if (![self exportDatabase]) { [self failWithErrorDescription: NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", @"Error indicating the backup export could not export the user's data.")]; - return; - } - if (self.isComplete) { - return; - } - [self saveToCloudWithCompletion:^(NSError *_Nullable saveError) { - if (saveError) { - [weakSelf failWithError:saveError]; - return; - } - [self cleanUpWithCompletion:^(NSError *_Nullable cleanUpError) { - if (cleanUpError) { - [weakSelf failWithError:cleanUpError]; - return; - } - [weakSelf succeed]; - }]; - }]; - }]; - }]; + }) retainUntilComplete]; } -- (void)configureExportWithCompletion:(OWSBackupJobBoolCompletion)completion +- (AnyPromise *)configureExport { - OWSAssertDebug(completion); - OWSLogVerbose(@""); + if (self.isComplete) { + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } + if (![self ensureJobTempDir]) { OWSFailDebug(@"Could not create jobTempDirPath."); - return completion(NO); + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not create jobTempDirPath.")]; } self.backupIO = [[OWSBackupIO alloc] initWithJobTempDirPath:self.jobTempDirPath]; @@ -431,55 +405,64 @@ NS_ASSUME_NONNULL_BEGIN // // We use an arbitrary request that requires authentication // to verify our account state. - TSRequest *currentSignedPreKey = [OWSRequestFactory currentSignedPreKeyRequest]; - [[TSNetworkManager sharedManager] makeRequest:currentSignedPreKey - success:^(NSURLSessionDataTask *task, NSDictionary *responseObject) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - completion(YES); - }); - } - failure:^(NSURLSessionDataTask *task, NSError *error) { - // TODO: We may want to surface this in the UI. - OWSLogError(@"could not verify account status: %@.", error); - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - completion(NO); - }); - }]; + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + TSRequest *currentSignedPreKey = [OWSRequestFactory currentSignedPreKeyRequest]; + [[TSNetworkManager sharedManager] makeRequest:currentSignedPreKey + success:^(NSURLSessionDataTask *task, NSDictionary *responseObject) { + resolve(@(1)); + } + failure:^(NSURLSessionDataTask *task, NSError *error) { + // TODO: We may want to surface this in the UI. + OWSLogError(@"could not verify account status: %@.", error); + resolve(error); + }]; + }]; } -- (void)fetchAllRecordsWithCompletion:(OWSBackupJobBoolCompletion)completion +- (AnyPromise *)fetchAllRecords { - OWSAssertDebug(completion); + OWSLogVerbose(@""); if (self.isComplete) { - return; + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; } + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId + success:^(NSArray *recordNames) { + if (self.isComplete) { + return resolve(OWSBackupErrorWithDescription(@"Backup export no longer active.")); + } + self.lastValidRecordNames = [NSSet setWithArray:recordNames]; + resolve(@(1)); + } + failure:^(NSError *error) { + resolve(error); + }]; + }]; +} + +- (AnyPromise *)exportDatabase +{ + OWSAssertDebug(self.backupIO); + OWSLogVerbose(@""); - __weak OWSBackupExportJob *weakSelf = self; - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId - success:^(NSArray *recordNames) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - OWSBackupExportJob *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - if (strongSelf.isComplete) { - return; - } - strongSelf.lastValidRecordNames = [NSSet setWithArray:recordNames]; - completion(YES); - }); + if (self.isComplete) { + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } + + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + if (![self performExportDatabase]) { + NSError *error = OWSBackupErrorWithDescription(@"Backup export failed."); + return resolve(error); } - failure:^(NSError *error) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - completion(NO); - }); - }]; + + resolve(@(1)); + }]; } -- (BOOL)exportDatabase +- (BOOL)performExportDatabase { OWSAssertDebug(self.backupIO); @@ -681,12 +664,14 @@ NS_ASSUME_NONNULL_BEGIN return YES; } -- (void)saveToCloudWithCompletion:(OWSBackupJobCompletion)completion +- (AnyPromise *)saveToCloud { - OWSAssertDebug(completion); - OWSLogVerbose(@""); + if (self.isComplete) { + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } + self.savedDatabaseItems = [NSMutableArray new]; self.savedAttachmentItems = [NSMutableArray new]; @@ -722,20 +707,6 @@ NS_ASSUME_NONNULL_BEGIN } OWSLogInfo(@"exporting %@: count: %zd, bytes: %llu.", @"all items", totalFileCount, totalFileSize); - [self saveNextFileToCloudWithCompletion:completion]; -} - -// This method uploads one file (the "next" file) each time it -// is called. Each successful file upload re-invokes this method -// until the last (the manifest file). -- (void)saveNextFileToCloudWithCompletion:(OWSBackupJobCompletion)completion -{ - OWSAssertDebug(completion); - - if (self.isComplete) { - return; - } - // Add one for the manifest NSUInteger unsavedCount = (self.unsavedDatabaseItems.count + self.unsavedAttachmentExports.count + 1); NSUInteger savedCount = (self.savedDatabaseItems.count + self.savedAttachmentItems.count); @@ -745,66 +716,67 @@ NS_ASSUME_NONNULL_BEGIN @"Indicates that the backup export data is being uploaded.") progress:@(progress)]; - if ([self saveNextDatabaseFileToCloudWithCompletion:completion]) { - return; - } - if ([self saveNextAttachmentFileToCloudWithCompletion:completion]) { - return; - } - [self saveManifestFileToCloudWithCompletion:completion]; + // Save attachment files _before_ anything else, since they + // are the only reusable backup records. + return [self saveAttachmentFilesToCloud] + .thenInBackground(^{ + return [self saveDatabaseFilesToCloud]; + }) + .thenInBackground(^{ + return [self saveManifestFileToCloud]; + }); } // This method returns YES IFF "work was done and there might be more work to do". -- (BOOL)saveNextDatabaseFileToCloudWithCompletion:(OWSBackupJobCompletion)completion +- (AnyPromise *)saveDatabaseFilesToCloud { - OWSAssertDebug(completion); - - __weak OWSBackupExportJob *weakSelf = self; - if (self.unsavedDatabaseItems.count < 1) { - return NO; + AnyPromise *promise = [AnyPromise promiseWithValue:@(1)]; + + // We need to preserve ordering of database shards. + for (OWSBackupExportItem *item in self.unsavedDatabaseItems) { + OWSAssertDebug(item.encryptedItem.filePath.length > 0); + + promise + = promise + .thenInBackground(^{ + if (self.isComplete) { + return [AnyPromise + promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } + + return [OWSBackupAPI + saveEphemeralDatabaseFileToCloudObjcWithRecipientId:self.recipientId + fileUrl:[NSURL fileURLWithPath:item.encryptedItem + .filePath]]; + }) + .thenInBackground(^(NSString *recordName) { + item.recordName = recordName; + [self.savedDatabaseItems addObject:item]; + }); } - - // Pop next item from queue, preserving ordering. - OWSBackupExportItem *item = self.unsavedDatabaseItems.firstObject; - [self.unsavedDatabaseItems removeObjectAtIndex:0]; - - OWSAssertDebug(item.encryptedItem.filePath.length > 0); - - [OWSBackupAPI saveEphemeralDatabaseFileToCloudWithRecipientId:self.recipientId - fileUrl:[NSURL fileURLWithPath:item.encryptedItem.filePath] - success:^(NSString *recordName) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - item.recordName = recordName; - [weakSelf.savedDatabaseItems addObject:item]; - [weakSelf saveNextFileToCloudWithCompletion:completion]; - }); - } - failure:^(NSError *error) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - // Database files are critical so any error uploading them is unrecoverable. - OWSLogVerbose(@"error while saving file: %@", item.encryptedItem.filePath); - completion(error); - }); - }]; - return YES; + [self.unsavedDatabaseItems removeAllObjects]; + return promise; } // This method returns YES IFF "work was done and there might be more work to do". -- (BOOL)saveNextAttachmentFileToCloudWithCompletion:(OWSBackupJobCompletion)completion +- (AnyPromise *)saveAttachmentFilesToCloud { - OWSAssertDebug(completion); + AnyPromise *promise = [AnyPromise promiseWithValue:@(1)]; - __weak OWSBackupExportJob *weakSelf = self; - if (self.unsavedAttachmentExports.count < 1) { - return NO; + for (OWSAttachmentExport *attachmentExport in self.unsavedAttachmentExports) { + promise = promise.thenInBackground(^{ + if (self.isComplete) { + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } + return [self saveAttachmentFileToCloud:attachmentExport]; + }); } + [self.unsavedAttachmentExports removeAllObjects]; + return promise; +} - // No need to preserve ordering of attachments. - OWSAttachmentExport *attachmentExport = self.unsavedAttachmentExports.lastObject; - [self.unsavedAttachmentExports removeLastObject]; - +- (AnyPromise *)saveAttachmentFileToCloud:(OWSAttachmentExport *)attachmentExport +{ if (self.lastValidRecordNames) { // Wherever possible, we do incremental backups and re-use fragments of the last // backup and/or restore. @@ -840,8 +812,7 @@ NS_ASSUME_NONNULL_BEGIN OWSLogVerbose(@"recycled attachment: %@ as %@", attachmentExport.attachmentFilePath, attachmentExport.relativeFilePath); - [self saveNextFileToCloudWithCompletion:completion]; - return YES; + return [AnyPromise promiseWithValue:@(1)]; } } @@ -850,117 +821,83 @@ NS_ASSUME_NONNULL_BEGIN // attachment to disk. if (![attachmentExport prepareForUpload]) { // Attachment files are non-critical so any error uploading them is recoverable. - [weakSelf saveNextFileToCloudWithCompletion:completion]; - return YES; + return [AnyPromise promiseWithValue:@(1)]; } OWSAssertDebug(attachmentExport.relativeFilePath.length > 0); OWSAssertDebug(attachmentExport.encryptedItem); } - [OWSBackupAPI savePersistentFileOnceToCloudWithRecipientId:self.recipientId - fileId:attachmentExport.attachmentId - fileUrlBlock:^{ - if (attachmentExport.encryptedItem.filePath.length < 1) { - OWSLogError(@"attachment export missing temp file path"); - return (NSURL *)nil; - } - if (attachmentExport.relativeFilePath.length < 1) { - OWSLogError(@"attachment export missing relative file path"); - return (NSURL *)nil; + return [OWSBackupAPI + savePersistentFileOnceToCloudObjcWithRecipientId:self.recipientId + fileId:attachmentExport.attachmentId + fileUrlBlock:^{ + if (attachmentExport.encryptedItem.filePath.length < 1) { + OWSLogError(@"attachment export missing temp file path"); + return (NSURL *)nil; + } + if (attachmentExport.relativeFilePath.length < 1) { + OWSLogError(@"attachment export missing relative file path"); + return (NSURL *)nil; + } + return [NSURL fileURLWithPath:attachmentExport.encryptedItem.filePath]; + }] + .thenInBackground(^(NSString *recordName) { + if (![attachmentExport cleanUp]) { + OWSLogError(@"couldn't clean up attachment export."); + // Attachment files are non-critical so any error uploading them is recoverable. } - return [NSURL fileURLWithPath:attachmentExport.encryptedItem.filePath]; - } - success:^(NSString *recordName) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - OWSBackupExportJob *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - if (![attachmentExport cleanUp]) { - OWSLogError(@"couldn't clean up attachment export."); - // Attachment files are non-critical so any error uploading them is recoverable. - } - OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; - exportItem.encryptedItem = attachmentExport.encryptedItem; - exportItem.recordName = recordName; - exportItem.attachmentExport = attachmentExport; - [strongSelf.savedAttachmentItems addObject:exportItem]; - - // Immediately save the record metadata to facilitate export resume. - OWSBackupFragment *backupFragment = [OWSBackupFragment new]; - backupFragment.recordName = recordName; - backupFragment.encryptionKey = exportItem.encryptedItem.encryptionKey; - backupFragment.relativeFilePath = attachmentExport.relativeFilePath; - backupFragment.attachmentId = attachmentExport.attachmentId; - backupFragment.uncompressedDataLength = exportItem.uncompressedDataLength; - [backupFragment save]; - - OWSLogVerbose(@"saved attachment: %@ as %@", - attachmentExport.attachmentFilePath, - attachmentExport.relativeFilePath); - [strongSelf saveNextFileToCloudWithCompletion:completion]; - }); - } - failure:^(NSError *error) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (![attachmentExport cleanUp]) { - OWSLogError(@"couldn't clean up attachment export."); - // Attachment files are non-critical so any error uploading them is recoverable. - } + OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; + exportItem.encryptedItem = attachmentExport.encryptedItem; + exportItem.recordName = recordName; + exportItem.attachmentExport = attachmentExport; + [self.savedAttachmentItems addObject:exportItem]; + // Immediately save the record metadata to facilitate export resume. + OWSBackupFragment *backupFragment = [OWSBackupFragment new]; + backupFragment.recordName = recordName; + backupFragment.encryptionKey = exportItem.encryptedItem.encryptionKey; + backupFragment.relativeFilePath = attachmentExport.relativeFilePath; + backupFragment.attachmentId = attachmentExport.attachmentId; + backupFragment.uncompressedDataLength = exportItem.uncompressedDataLength; + [backupFragment save]; + + OWSLogVerbose( + @"saved attachment: %@ as %@", attachmentExport.attachmentFilePath, attachmentExport.relativeFilePath); + }) + .catchInBackground(^{ + if (![attachmentExport cleanUp]) { + OWSLogError(@"couldn't clean up attachment export."); // Attachment files are non-critical so any error uploading them is recoverable. - [weakSelf saveNextFileToCloudWithCompletion:completion]; - }); - }]; + } - return YES; + // Attachment files are non-critical so any error uploading them is recoverable. + return [AnyPromise promiseWithValue:@(1)]; + }); } -- (void)saveManifestFileToCloudWithCompletion:(OWSBackupJobCompletion)completion +- (AnyPromise *)saveManifestFileToCloud { - OWSAssertDebug(completion); + if (self.isComplete) { + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; + } OWSBackupEncryptedItem *_Nullable encryptedItem = [self writeManifestFile]; if (!encryptedItem) { - completion(OWSErrorWithCodeDescription(OWSErrorCodeExportBackupFailed, - NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", - @"Error indicating the backup export could not export the user's data."))); - return; + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not generate manifest.")]; } OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; exportItem.encryptedItem = encryptedItem; - __weak OWSBackupExportJob *weakSelf = self; - - [OWSBackupAPI upsertManifestFileToCloudWithRecipientId:self.recipientId - fileUrl:[NSURL fileURLWithPath:encryptedItem.filePath] - success:^(NSString *recordName) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - OWSBackupExportJob *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - exportItem.recordName = recordName; - strongSelf.manifestItem = exportItem; + return [OWSBackupAPI upsertManifestFileToCloudObjcWithRecipientId:self.recipientId + fileUrl:[NSURL fileURLWithPath:encryptedItem.filePath]] + .thenInBackground(^(NSString *recordName) { + exportItem.recordName = recordName; + self.manifestItem = exportItem; - // All files have been saved to the cloud. - completion(nil); - }); - } - failure:^(NSError *error) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - // The manifest file is critical so any error uploading them is unrecoverable. - completion(error); - }); - }]; + // All files have been saved to the cloud. + }); } - (nullable OWSBackupEncryptedItem *)writeManifestFile @@ -1014,13 +951,11 @@ NS_ASSUME_NONNULL_BEGIN return result; } -- (void)cleanUpWithCompletion:(OWSBackupJobCompletion)completion +- (AnyPromise *)cleanUp { - OWSAssertDebug(completion); - if (self.isComplete) { // Job was aborted. - return completion(nil); + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; } OWSLogVerbose(@""); @@ -1056,7 +991,7 @@ NS_ASSUME_NONNULL_BEGIN [self cleanUpMetadataCacheWithActiveRecordNames:activeRecordNames]; - [self cleanUpCloudWithActiveRecordNames:activeRecordNames completion:completion]; + return [self cleanUpCloudWithActiveRecordNames:activeRecordNames]; } - (void)cleanUpMetadataCacheWithActiveRecordNames:(NSSet *)activeRecordNames @@ -1080,22 +1015,18 @@ NS_ASSUME_NONNULL_BEGIN }]; } -- (void)cleanUpCloudWithActiveRecordNames:(NSSet *)activeRecordNames - completion:(OWSBackupJobCompletion)completion +- (AnyPromise *)cleanUpCloudWithActiveRecordNames:(NSSet *)activeRecordNames { OWSAssertDebug(activeRecordNames.count > 0); - OWSAssertDebug(completion); if (self.isComplete) { // Job was aborted. - return completion(nil); + return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; } - __weak OWSBackupExportJob *weakSelf = self; - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId - success:^(NSArray *recordNames) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId + success:^(NSArray *recordNames) { NSMutableSet *obsoleteRecordNames = [NSMutableSet new]; [obsoleteRecordNames addObjectsFromArray:recordNames]; [obsoleteRecordNames minusSet:activeRecordNames]; @@ -1105,18 +1036,18 @@ NS_ASSUME_NONNULL_BEGIN activeRecordNames.count, obsoleteRecordNames.count); - [weakSelf deleteRecordsFromCloud:[obsoleteRecordNames.allObjects mutableCopy] - deletedCount:0 - completion:completion]; - }); - } - failure:^(NSError *error) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self deleteRecordsFromCloud:[obsoleteRecordNames.allObjects mutableCopy] + deletedCount:0 + completion:^(NSError *_Nullable error) { + // Cloud cleanup is non-critical so any error is recoverable. + resolve(@(1)); + }]; + } + failure:^(NSError *error) { // Cloud cleanup is non-critical so any error is recoverable. - completion(nil); - }); - }]; + resolve(@(1)); + }]; + }]; } - (void)deleteRecordsFromCloud:(NSMutableArray *)obsoleteRecordNames @@ -1151,24 +1082,17 @@ NS_ASSUME_NONNULL_BEGIN [batchRecordNames addObject:recordName]; } - __weak OWSBackupExportJob *weakSelf = self; [OWSBackupAPI deleteRecordsFromCloudWithRecordNames:batchRecordNames success:^{ - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [weakSelf deleteRecordsFromCloud:obsoleteRecordNames - deletedCount:deletedCount + batchRecordNames.count - completion:completion]; - }); + [self deleteRecordsFromCloud:obsoleteRecordNames + deletedCount:deletedCount + batchRecordNames.count + completion:completion]; } failure:^(NSError *error) { - // Ensure that we continue to work off the main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - // Cloud cleanup is non-critical so any error is recoverable. - [weakSelf deleteRecordsFromCloud:obsoleteRecordNames - deletedCount:deletedCount + batchRecordNames.count - completion:completion]; - }); + // Cloud cleanup is non-critical so any error is recoverable. + [self deleteRecordsFromCloud:obsoleteRecordNames + deletedCount:deletedCount + batchRecordNames.count + completion:completion]; }]; } diff --git a/Signal/src/util/Backup/OWSBackupImportJob.m b/Signal/src/util/Backup/OWSBackupImportJob.m index 5bc7333ca..736fbab31 100644 --- a/Signal/src/util/Backup/OWSBackupImportJob.m +++ b/Signal/src/util/Backup/OWSBackupImportJob.m @@ -7,6 +7,7 @@ #import "OWSDatabaseMigration.h" #import "OWSDatabaseMigrationRunner.h" #import "Signal-Swift.h" +#import #import #import #import @@ -56,6 +57,13 @@ NSString *const kOWSBackup_ImportDatabaseKeySpec = @"kOWSBackup_ImportDatabaseKe return SSKEnvironment.shared.tsAccountManager; } +- (OWSBackup *)backup +{ + OWSAssertDebug(AppEnvironment.shared.backup); + + return AppEnvironment.shared.backup; +} + #pragma mark - - (void)startAsync @@ -69,17 +77,15 @@ NSString *const kOWSBackup_ImportDatabaseKeySpec = @"kOWSBackup_ImportDatabaseKe [self updateProgressWithDescription:nil progress:nil]; __weak OWSBackupImportJob *weakSelf = self; - [OWSBackupAPI checkCloudKitAccessWithCompletion:^(BOOL hasAccess) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (hasAccess) { + [[self.backup ensureCloudKitAccess] + .thenInBackground(^{ [weakSelf start]; - } else { + }) + .catch(^(NSError *error) { [weakSelf failWithErrorDescription: NSLocalizedString(@"BACKUP_IMPORT_ERROR_COULD_NOT_IMPORT", @"Error indicating the backup import could not import the user's data.")]; - } - }); - }]; + }) retainUntilComplete]; } - (void)start diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 1fbe80355..3fe9a8908 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -243,7 +243,7 @@ "BACKUP_RESTORE_STATUS" = "Status"; /* Error shown when backup fails due to an unexpected error. */ -"BACKUP_UNEXPECTED_ERROR" = "Unexpected Error"; +"BACKUP_UNEXPECTED_ERROR" = "Unexpected Backup Error"; /* An explanation of the consequences of blocking a group. */ "BLOCK_GROUP_BEHAVIOR_EXPLANATION" = "You will no longer receive messages or updates from this group."; @@ -410,14 +410,14 @@ /* The label for the 'restore backup' button. */ "CHECK_FOR_BACKUP_RESTORE" = "Restore"; -/* Error indicating that the app could not determine that user's CloudKit account status */ -"CLOUDKIT_STATUS_COULD_NOT_DETERMINE" = "There was an error communicating with iCloud for backups."; +/* Error indicating that the app could not determine that user's iCloud account status */ +"CLOUDKIT_STATUS_COULD_NOT_DETERMINE" = "Signal could not determine your iCloud account status. Sign in to your iCloud Account in the iOS settings app to backup your Signal data."; /* Error indicating that user does not have an iCloud account. */ -"CLOUDKIT_STATUS_NO_ACCOUNT" = "You do not have an iCloud Account for backups."; +"CLOUDKIT_STATUS_NO_ACCOUNT" = "No iCloud Account. Sign in to your iCloud Account in the iOS settings app to backup your Signal data."; -/* Error indicating that the app was prevented from accessing the user's CloudKit account. */ -"CLOUDKIT_STATUS_RESTRICTED" = "Signal was not allowed to access your iCloud account for backups."; +/* Error indicating that the app was prevented from accessing the user's iCloud account. */ +"CLOUDKIT_STATUS_RESTRICTED" = "Signal was denied access your iCloud account for backups. Grant Signal access to your iCloud Account in the iOS settings app to backup your Signal data."; /* The first of two messages demonstrating the chosen conversation color, by rendering this message in an outgoing message bubble. */ "COLOR_PICKER_DEMO_MESSAGE_1" = "Choose the color of outgoing messages in this conversation."; @@ -1964,6 +1964,9 @@ /* Label for switch in settings that controls whether or not backup is enabled. */ "SETTINGS_BACKUP_ENABLING_SWITCH" = "Backup Enabled"; +/* Label for iCloud status row in the in the backup settings view. */ +"SETTINGS_BACKUP_ICLOUD_STATUS" = "iCloud Status"; + /* Indicates that the last backup restore failed. */ "SETTINGS_BACKUP_IMPORT_STATUS_FAILED" = "Backup Restore Failed"; @@ -1982,7 +1985,7 @@ /* Label for phase row in the in the backup settings view. */ "SETTINGS_BACKUP_PROGRESS" = "Progress"; -/* Label for status row in the in the backup settings view. */ +/* Label for backup status row in the in the backup settings view. */ "SETTINGS_BACKUP_STATUS" = "Status"; /* Indicates that the last backup failed. */ diff --git a/SignalMessaging/ViewControllers/OWSTableViewController.h b/SignalMessaging/ViewControllers/OWSTableViewController.h index 0185f88b2..bbf69e576 100644 --- a/SignalMessaging/ViewControllers/OWSTableViewController.h +++ b/SignalMessaging/ViewControllers/OWSTableViewController.h @@ -101,6 +101,8 @@ typedef UITableViewCell *_Nonnull (^OWSTableCustomCellBlock)(void); + (OWSTableItem *)labelItemWithText:(NSString *)text accessoryText:(NSString *)accessoryText; ++ (OWSTableItem *)longDisclosureItemWithText:(NSString *)text actionBlock:(nullable OWSTableActionBlock)actionBlock; + + (OWSTableItem *)switchItemWithText:(NSString *)text isOn:(BOOL)isOn target:(id)target selector:(SEL)selector; + (OWSTableItem *)switchItemWithText:(NSString *)text diff --git a/SignalMessaging/ViewControllers/OWSTableViewController.m b/SignalMessaging/ViewControllers/OWSTableViewController.m index f4aeb07fb..99291855d 100644 --- a/SignalMessaging/ViewControllers/OWSTableViewController.m +++ b/SignalMessaging/ViewControllers/OWSTableViewController.m @@ -346,6 +346,26 @@ const CGFloat kOWSTable_DefaultCellHeight = 45.f; return item; } ++ (OWSTableItem *)longDisclosureItemWithText:(NSString *)text actionBlock:(nullable OWSTableActionBlock)actionBlock +{ + OWSAssertDebug(text.length > 0); + + OWSTableItem *item = [OWSTableItem new]; + item.customCellBlock = ^{ + UITableViewCell *cell = [OWSTableItem newCell]; + + cell.textLabel.text = text; + cell.textLabel.numberOfLines = 0; + cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + + return cell; + }; + item.customRowHeight = @(UITableViewAutomaticDimension); + item.actionBlock = actionBlock; + return item; +} + + (OWSTableItem *)switchItemWithText:(NSString *)text isOn:(BOOL)isOn target:(id)target selector:(SEL)selector { return [self switchItemWithText:text isOn:isOn isEnabled:YES target:target selector:selector];