Merge branch 'charlesmchen/backupPromises'

pull/1/head
Matthew Chen 6 years ago
commit 033044c1f5

@ -6,6 +6,7 @@
#import "OWSBackup.h"
#import "Signal-Swift.h"
#import "ThreadUtil.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalMessaging/AttachmentSharing.h>
#import <SignalMessaging/Environment.h>
#import <SignalMessaging/SignalMessaging-Swift.h>
@ -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

@ -6,6 +6,7 @@
#import "OWSBackup.h"
#import "OWSTableViewController.h"
#import "Signal-Swift.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalCoreKit/Randomness.h>
@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

@ -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) {

@ -26,6 +26,9 @@ NSString *NSStringForBackupImportState(OWSBackupState state);
NSArray<NSString *> *MiscCollectionsToBackup(void);
NSError *OWSBackupErrorWithDescription(NSString *description);
@class AnyPromise;
@class OWSBackupIO;
@class TSAttachmentPointer;
@class TSThread;
@ -71,6 +74,10 @@ NSArray<NSString *> *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

@ -7,10 +7,13 @@
#import "OWSBackupIO.h"
#import "OWSBackupImportJob.h"
#import "Signal-Swift.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalCoreKit/Randomness.h>
#import <SignalServiceKit/OWSIdentityManager.h>
#import <SignalServiceKit/YapDatabaseConnection+OWS.h>
@import CloudKit;
NS_ASSUME_NONNULL_BEGIN
NSString *const NSNotificationNameBackupStateDidChange = @"NSNotificationNameBackupStateDidChange";
@ -62,6 +65,17 @@ NSArray<NSString *> *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 () <OWSBackupJobDelegate>
@ -131,6 +145,10 @@ NSArray<NSString *> *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<NSString *> *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<NSString *> *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<NSString *> *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<NSString *> *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<NSString *> *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.

@ -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<String> {
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<String> {
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<String> {
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<String> {
// 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<String> {
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<String> {
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<String> {
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<String> {
return checkForFileInCloud(recordName: recordName,
remainingRetries: maxRetries)
.then { (record: CKRecord?) -> Promise<String> 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<String> {
return checkForFileInCloud(recordName: recordName,
remainingRetries: maxRetries)
.then { (record: CKRecord?) -> Promise<String> 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<CKRecord?> {
let (promise, resolver) = Promise<CKRecord?>.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<Bool> {
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<Void> {
let (promise, resolver) = Promise<Void>.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.

@ -8,7 +8,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface OWSBackupExportJob : OWSBackupJob
- (void)startAsync;
- (void)start;
@end

@ -6,6 +6,7 @@
#import "OWSBackupIO.h"
#import "OWSDatabaseMigration.h"
#import "Signal-Swift.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalCoreKit/NSData+OWS.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SignalCoreKit/Threading.h>
@ -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<NSString *> *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<NSString *> *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<NSString *> *)activeRecordNames
@ -1080,22 +1015,18 @@ NS_ASSUME_NONNULL_BEGIN
}];
}
- (void)cleanUpCloudWithActiveRecordNames:(NSSet<NSString *> *)activeRecordNames
completion:(OWSBackupJobCompletion)completion
- (AnyPromise *)cleanUpCloudWithActiveRecordNames:(NSSet<NSString *> *)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<NSString *> *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<NSString *> *recordNames) {
NSMutableSet<NSString *> *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<NSString *> *)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];
}];
}

@ -7,6 +7,7 @@
#import "OWSDatabaseMigration.h"
#import "OWSDatabaseMigrationRunner.h"
#import "Signal-Swift.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalCoreKit/NSData+OWS.h>
#import <SignalServiceKit/OWSBackgroundTask.h>
#import <SignalServiceKit/OWSFileSystem.h>
@ -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

@ -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. */

@ -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

@ -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];

Loading…
Cancel
Save