diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index d198f2852..6757d6c06 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 340FC8C2204DDF67007AEB0F /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 340FC8C1204DDF66007AEB0F /* CloudKit.framework */; }; 340FC8C5204DE223007AEB0F /* DebugUIBackup.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8C4204DE223007AEB0F /* DebugUIBackup.m */; }; 340FC8C7204DE64D007AEB0F /* OWSBackupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8C6204DE64D007AEB0F /* OWSBackupAPI.swift */; }; + 340FC8CA20517B84007AEB0F /* OWSBackupImport.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8C820517B84007AEB0F /* OWSBackupImport.m */; }; 341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */ = {isa = PBXBuildFile; fileRef = 341F2C0E1F2B8AE700D07D6B /* DebugUIMisc.m */; }; 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; }; 34330A5A1E7875FB00DF2FB9 /* fontawesome-webfont.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 34330A591E7875FB00DF2FB9 /* fontawesome-webfont.ttf */; }; @@ -577,6 +578,8 @@ 340FC8C3204DE223007AEB0F /* DebugUIBackup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIBackup.h; sourceTree = ""; }; 340FC8C4204DE223007AEB0F /* DebugUIBackup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIBackup.m; sourceTree = ""; }; 340FC8C6204DE64D007AEB0F /* OWSBackupAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSBackupAPI.swift; sourceTree = ""; }; + 340FC8C820517B84007AEB0F /* OWSBackupImport.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupImport.m; sourceTree = ""; }; + 340FC8C920517B84007AEB0F /* OWSBackupImport.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupImport.h; sourceTree = ""; }; 341458471FBE11C4005ABCF9 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = translations/fa.lproj/Localizable.strings; sourceTree = ""; }; 341F2C0D1F2B8AE700D07D6B /* DebugUIMisc.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIMisc.h; sourceTree = ""; }; 341F2C0E1F2B8AE700D07D6B /* DebugUIMisc.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIMisc.m; sourceTree = ""; }; @@ -1936,6 +1939,8 @@ 340FC8C6204DE64D007AEB0F /* OWSBackupAPI.swift */, 340FC8BE204DB7D1007AEB0F /* OWSBackupExport.h */, 340FC8BF204DB7D2007AEB0F /* OWSBackupExport.m */, + 340FC8C920517B84007AEB0F /* OWSBackupImport.h */, + 340FC8C820517B84007AEB0F /* OWSBackupImport.m */, 4579431C1E7C8CE9008ED0C0 /* Pastelog.h */, 4579431D1E7C8CE9008ED0C0 /* Pastelog.m */, 450DF2041E0D74AC003D14BE /* Platform.swift */, @@ -3170,6 +3175,7 @@ 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */, 45F170BB1E2FC5D3003FC1F2 /* CallAudioService.swift in Sources */, 345BC30C2047030700257B7C /* OWS2FASettingsViewController.m in Sources */, + 340FC8CA20517B84007AEB0F /* OWSBackupImport.m in Sources */, 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m b/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m index 9f8069ce3..6f0ce763d 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIBackup.m @@ -28,6 +28,14 @@ NS_ASSUME_NONNULL_BEGIN actionBlock:^{ [DebugUIBackup backupTestFile]; }]]; + [items addObject:[OWSTableItem itemWithTitle:@"Check for CloudKit backup" + actionBlock:^{ + [DebugUIBackup checkForBackup]; + }]]; + [items addObject:[OWSTableItem itemWithTitle:@"Try to restore CloudKit backup" + actionBlock:^{ + [DebugUIBackup tryToImportBackup]; + }]]; return [OWSTableSection sectionWithTitle:self.name items:items]; } @@ -55,6 +63,25 @@ NS_ASSUME_NONNULL_BEGIN }]; } ++ (void)checkForBackup +{ + DDLogInfo(@"%@ checkForBackup.", self.logTag); + + [OWSBackup.sharedManager checkCanImportBackup:^(BOOL value) { + DDLogInfo(@"%@ has backup available for import? %d", self.logTag, value); + } + failure:^(NSError *error){ + // Do nothing. + }]; +} + ++ (void)tryToImportBackup +{ + DDLogInfo(@"%@ tryToImportBackup.", self.logTag); + + [OWSBackup.sharedManager tryToImportBackup]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/util/OWSBackup.h b/Signal/src/util/OWSBackup.h index 64687acb1..3e072aaee 100644 --- a/Signal/src/util/OWSBackup.h +++ b/Signal/src/util/OWSBackup.h @@ -6,7 +6,7 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const NSNotificationNameBackupStateDidChange; -typedef void (^OWSBackupBoolBlock)(BOOL success); +typedef void (^OWSBackupBoolBlock)(BOOL value); typedef void (^OWSBackupErrorBlock)(NSError *error); typedef NS_ENUM(NSUInteger, OWSBackupState) { @@ -24,16 +24,43 @@ typedef NS_ENUM(NSUInteger, OWSBackupState) { @interface OWSBackup : NSObject -@property (nonatomic, readonly) OWSBackupState backupExportState; - - (instancetype)init NS_UNAVAILABLE; + (instancetype)sharedManager; +- (void)setup; + +#pragma mark - Backup Export + +@property (nonatomic, readonly) OWSBackupState backupExportState; +// If a "backup export" is in progress (see backupExportState), +// backupExportDescription _might_ contain a string that describes +// the current phase and backupExportProgress _might_ contain a +// 0.0<=x<=1.0 progress value that indicates progress within the +// current phase. +@property (nonatomic, readonly, nullable) NSString *backupExportDescription; +@property (nonatomic, readonly, nullable) NSNumber *backupExportProgress; + - (BOOL)isBackupEnabled; - (void)setIsBackupEnabled:(BOOL)value; -- (void)setup; +#pragma mark - Backup Import + +@property (nonatomic, readonly) OWSBackupState backupImportState; +// If a "backup import" is in progress (see backupImportState), +// backupImportDescription _might_ contain a string that describes +// the current phase and backupImportProgress _might_ contain a +// 0.0<=x<=1.0 progress value that indicates progress within the +// current phase. +@property (nonatomic, readonly, nullable) NSString *backupImportDescription; +@property (nonatomic, readonly, nullable) NSNumber *backupImportProgress; + +- (void)checkCanImportBackup:(OWSBackupBoolBlock)success failure:(OWSBackupErrorBlock)failure; + +// TODO: After a successful import, we should enable backup and +// preserve our PIN and/or private key so that restored users +// continues to backup. +- (void)tryToImportBackup; @end diff --git a/Signal/src/util/OWSBackup.m b/Signal/src/util/OWSBackup.m index 93ffa0dd8..18ed53c4f 100644 --- a/Signal/src/util/OWSBackup.m +++ b/Signal/src/util/OWSBackup.m @@ -5,6 +5,7 @@ #import "OWSBackup.h" #import "NSNotificationCenter+OWS.h" #import "OWSBackupExport.h" +#import "OWSBackupImport.h" #import "Signal-Swift.h" #import #import @@ -25,13 +26,22 @@ NSString *const OWSBackup_LastExportFailureDateKey = @"OWSBackup_LastExportFailu NS_ASSUME_NONNULL_BEGIN // TODO: Observe Reachability. -@interface OWSBackup () +@interface OWSBackup () @property (nonatomic, readonly) YapDatabaseConnection *dbConnection; // This property should only be accessed on the main thread. @property (nonatomic, nullable) OWSBackupExport *backupExport; +// This property should only be accessed on the main thread. +@property (nonatomic, nullable) OWSBackupImport *backupImport; + +@property (nonatomic, nullable) NSString *backupExportDescription; +@property (nonatomic, nullable) NSNumber *backupExportProgress; + +@property (nonatomic, nullable) NSString *backupImportDescription; +@property (nonatomic, nullable) NSNumber *backupImportProgress; + @end #pragma mark - @@ -70,6 +80,7 @@ NS_ASSUME_NONNULL_BEGIN _dbConnection = primaryStorage.newDatabaseConnection; _backupExportState = OWSBackupState_Idle; + _backupImportState = OWSBackupState_Idle; OWSSingletonAssert(); @@ -183,9 +194,7 @@ NS_ASSUME_NONNULL_BEGIN inCollection:OWSPrimaryStorage_OWSBackupCollection]; } - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:NSNotificationNameBackupStateDidChange - object:nil - userInfo:nil]; + [self postDidChangeNotification]; [self ensureBackupExportState]; } @@ -220,6 +229,13 @@ NS_ASSUME_NONNULL_BEGIN if (lastExportFailureDate && fabs(lastExportFailureDate.timeIntervalSinceNow) < kRetryAfterFailure) { return NO; } + // Don't export backup if there's an import in progress. + // + // This conflict shouldn't occur in production since we won't enable backup + // export until an import is complete, but this could happen in development. + if (self.backupImport) { + return NO; + } // TODO: There's other conditions that affect this decision, // e.g. Reachability, wifi v. cellular, etc. @@ -264,9 +280,7 @@ NS_ASSUME_NONNULL_BEGIN BOOL stateDidChange = _backupExportState != backupExportState; _backupExportState = backupExportState; if (stateDidChange) { - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:NSNotificationNameBackupStateDidChange - object:nil - userInfo:nil]; + [self postDidChangeNotification]; } } @@ -290,6 +304,24 @@ NS_ASSUME_NONNULL_BEGIN }]; } +- (void)tryToImportBackup +{ + OWSAssertIsOnMainThread(); + OWSAssert(!self.backupImport); + + // In development, make sure there's no export or import in progress. + [self.backupExport cancel]; + self.backupExport = nil; + [self.backupImport cancel]; + self.backupImport = nil; + + _backupImportState = OWSBackupState_InProgress; + + self.backupImport = + [[OWSBackupImport alloc] initWithDelegate:self primaryStorage:[OWSPrimaryStorage sharedManager]]; + [self.backupImport startAsync]; +} + #pragma mark - - (void)applicationDidBecomeActive:(NSNotification *)notification @@ -316,6 +348,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)backupExportDidSucceed:(OWSBackupExport *)backupExport { + OWSAssertIsOnMainThread(); + if (self.backupExport != backupExport) { return; } @@ -331,6 +365,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)backupExportDidFail:(OWSBackupExport *)backupExport error:(NSError *)error { + OWSAssertIsOnMainThread(); + if (self.backupExport != backupExport) { return; } @@ -344,6 +380,88 @@ NS_ASSUME_NONNULL_BEGIN [self ensureBackupExportState]; } +- (void)backupExportDidUpdate:(OWSBackupExport *)backupExport + description:(nullable NSString *)description + progress:(nullable NSNumber *)progress +{ + OWSAssertIsOnMainThread(); + + if (self.backupExport != backupExport) { + return; + } + + DDLogInfo(@"%@ %s: %@, %@", self.logTag, __PRETTY_FUNCTION__, description, progress); + + self.backupExportDescription = description; + self.backupExportProgress = progress; + + [self postDidChangeNotification]; +} + + +#pragma mark - OWSBackupImportDelegate + +- (void)backupImportDidSucceed:(OWSBackupImport *)backupImport +{ + OWSAssertIsOnMainThread(); + + if (self.backupImport != backupImport) { + return; + } + + DDLogInfo(@"%@ %s.", self.logTag, __PRETTY_FUNCTION__); + + self.backupImport = nil; + + _backupImportState = OWSBackupState_Succeeded; + + [self postDidChangeNotification]; +} + +- (void)backupImportDidFail:(OWSBackupImport *)backupImport error:(NSError *)error +{ + OWSAssertIsOnMainThread(); + + if (self.backupImport != backupImport) { + return; + } + + DDLogInfo(@"%@ %s: %@", self.logTag, __PRETTY_FUNCTION__, error); + + self.backupImport = nil; + + _backupImportState = OWSBackupState_Failed; + + [self postDidChangeNotification]; +} + +- (void)backupImportDidUpdate:(OWSBackupImport *)backupImport + description:(nullable NSString *)description + progress:(nullable NSNumber *)progress +{ + OWSAssertIsOnMainThread(); + + if (self.backupImport != backupImport) { + return; + } + + DDLogInfo(@"%@ %s: %@, %@", self.logTag, __PRETTY_FUNCTION__, description, progress); + + self.backupImportDescription = description; + self.backupImportProgress = progress; + + [self postDidChangeNotification]; +} + +#pragma mark - Notifications + +- (void)postDidChangeNotification +{ + [[NSNotificationCenter defaultCenter] postNotificationNameAsync:NSNotificationNameBackupStateDidChange + object:nil + userInfo:nil]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/util/OWSBackupExport.h b/Signal/src/util/OWSBackupExport.h index f29f77cd6..f09092ff2 100644 --- a/Signal/src/util/OWSBackupExport.h +++ b/Signal/src/util/OWSBackupExport.h @@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)backupExportDidSucceed:(OWSBackupExport *)backupExport; - (void)backupExportDidFail:(OWSBackupExport *)backupExport error:(NSError *)error; +- (void)backupExportDidUpdate:(OWSBackupExport *)backupExport + description:(nullable NSString *)description + progress:(nullable NSNumber *)progress; + @end //#pragma mark - diff --git a/Signal/src/util/OWSBackupExport.m b/Signal/src/util/OWSBackupExport.m index d5d5f4131..1d41a358b 100644 --- a/Signal/src/util/OWSBackupExport.m +++ b/Signal/src/util/OWSBackupExport.m @@ -172,6 +172,8 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); self.backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; + [self updateProgressWithDescription:nil progress:nil]; + __weak OWSBackupExport *weakSelf = self; [OWSBackupAPI checkCloudKitAccessWithCompletion:^(BOOL hasAccess) { if (hasAccess) { @@ -184,6 +186,10 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); - (void)start { + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CONFIGURATION", + @"Indicates that the backup export is being configured.") + progress:nil]; + __weak OWSBackupExport *weakSelf = self; [self configureExport:^(BOOL success) { if (!success) { @@ -196,6 +202,9 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable 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", @@ -283,6 +292,7 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); self.attachmentFilePathMap = [NSMutableDictionary new]; [self.srcDBConnection readWithBlock:^(YapDatabaseReadTransaction *srcTransaction) { + [self.dstDBConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *dstTransaction) { // Copy threads. [srcTransaction @@ -405,6 +415,12 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); return; } + CGFloat progress + = (self.databaseRecordMap.count / (CGFloat)(self.databaseRecordMap.count + self.databaseFilePaths.count)); + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_UPLOAD", + @"Indicates that the backup export data is being uploaded.") + progress:@(progress)]; + __weak OWSBackupExport *weakSelf = self; if (self.databaseFilePaths.count > 0) { @@ -545,6 +561,10 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", + @"Indicates that the cloud is being cleaned up.") + progress:nil]; + // Now that our backup export has successfully completed, // we try to clean up the cloud. We can safely delete any // records not involved in this backup export. @@ -573,7 +593,9 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); activeRecordNames.count, obsoleteRecordNames.count); - [weakSelf deleteRecordsFromCloud:[obsoleteRecordNames.allObjects mutableCopy] completion:completion]; + [weakSelf deleteRecordsFromCloud:[obsoleteRecordNames.allObjects mutableCopy] + deletedCount:0 + completion:completion]; }); } failure:^(NSError *error) { @@ -586,6 +608,7 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); } - (void)deleteRecordsFromCloud:(NSMutableArray *)obsoleteRecordNames + deletedCount:(NSUInteger)deletedCount completion:(OWSBackupExportCompletion)completion { OWSAssert(obsoleteRecordNames); @@ -599,6 +622,11 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); return; } + CGFloat progress = (obsoleteRecordNames.count / (CGFloat)(obsoleteRecordNames.count + deletedCount)); + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", + @"Indicates that the cloud is being cleaned up.") + progress:@(progress)]; + NSString *recordName = obsoleteRecordNames.lastObject; [obsoleteRecordNames removeLastObject]; @@ -607,14 +635,18 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); 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 completion:completion]; + [weakSelf deleteRecordsFromCloud:obsoleteRecordNames + deletedCount:deletedCount + 1 + 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 completion:completion]; + [weakSelf deleteRecordsFromCloud:obsoleteRecordNames + deletedCount:deletedCount + 1 + completion:completion]; }); }]; } @@ -662,6 +694,20 @@ typedef void (^OWSBackupExportCompletion)(NSError *_Nullable error); }); } +- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress +{ + DDLogInfo(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.isComplete) { + return; + } + [self.delegate backupExportDidUpdate:self description:description progress:progress]; + }); +} + +#pragma mark - Encryption + + (nullable NSString *)encryptAsTempFile:(NSString *)srcFilePath exportDirPath:(NSString *)exportDirPath delegate:(id)delegate diff --git a/Signal/src/util/OWSBackupImport.h b/Signal/src/util/OWSBackupImport.h new file mode 100644 index 000000000..51c56b2f0 --- /dev/null +++ b/Signal/src/util/OWSBackupImport.h @@ -0,0 +1,46 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@class OWSBackupImport; + +@protocol OWSBackupImportDelegate + +// TODO: This should eventually be the backup key stored in the Signal Service +// and retrieved with the backup PIN. +- (nullable NSData *)backupKey; + +// Either backupImportDidSucceed:... or backupImportDidFail:... will +// be called exactly once on the main thread UNLESS: +// +// * The import was never started. +// * The import was cancelled. +- (void)backupImportDidSucceed:(OWSBackupImport *)backupImport; +- (void)backupImportDidFail:(OWSBackupImport *)backupImport error:(NSError *)error; + +- (void)backupImportDidUpdate:(OWSBackupImport *)backupImport + description:(nullable NSString *)description + progress:(nullable NSNumber *)progress; + +@end + +//#pragma mark - + +@class OWSPrimaryStorage; + +@interface OWSBackupImport : NSObject + +- (instancetype)init NS_UNAVAILABLE; + +- (instancetype)initWithDelegate:(id)delegate + primaryStorage:(OWSPrimaryStorage *)primaryStorage; + +- (void)startAsync; + +- (void)cancel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Signal/src/util/OWSBackupImport.m b/Signal/src/util/OWSBackupImport.m new file mode 100644 index 000000000..04dbb963e --- /dev/null +++ b/Signal/src/util/OWSBackupImport.m @@ -0,0 +1,735 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "OWSBackupImport.h" +#import "Signal-Swift.h" +#import "zlib.h" +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^OWSBackupImportBoolCompletion)(BOOL success); +typedef void (^OWSBackupImportCompletion)(NSError *_Nullable error); + +@interface OWSBackupImport (Private) + ++ (nullable NSString *)encryptAsTempFile:(NSString *)srcFilePath + importDirPath:(NSString *)importDirPath + delegate:(id)delegate; + +@end + +#pragma mark - + +@interface OWSAttachmentImport : NSObject + +@property (nonatomic, weak) id delegate; +@property (nonatomic) NSString *importDirPath; +@property (nonatomic) NSString *attachmentId; +@property (nonatomic) NSString *attachmentFilePath; +@property (nonatomic, nullable) NSString *tempFilePath; +@property (nonatomic, nullable) NSString *relativeFilePath; + +@end + +#pragma mark - + +@implementation OWSAttachmentImport + +- (void)dealloc +{ + // Surface memory leaks by logging the deallocation. + DDLogVerbose(@"Dealloc: %@", self.class); + + // Delete temporary file ASAP. + if (self.tempFilePath) { + [OWSFileSystem deleteFileIfExists:self.tempFilePath]; + } +} + +// On success, tempFilePath will be non-nil. +- (void)prepareForUpload +{ + OWSAssert(self.importDirPath.length > 0); + OWSAssert(self.attachmentId.length > 0); + OWSAssert(self.attachmentFilePath.length > 0); + + NSString *attachmentsDirPath = [TSAttachmentStream attachmentsFolder]; + if (![self.attachmentFilePath hasPrefix:attachmentsDirPath]) { + DDLogError(@"%@ attachment has unexpected path.", self.logTag); + OWSFail(@"%@ attachment has unexpected path: %@", self.logTag, self.attachmentFilePath); + return; + } + NSString *relativeFilePath = [self.attachmentFilePath substringFromIndex:attachmentsDirPath.length]; + NSString *pathSeparator = @"/"; + if ([relativeFilePath hasPrefix:pathSeparator]) { + relativeFilePath = [relativeFilePath substringFromIndex:pathSeparator.length]; + } + self.relativeFilePath = relativeFilePath; + + NSString *_Nullable tempFilePath = [OWSBackupImport encryptAsTempFile:self.attachmentFilePath + importDirPath:self.importDirPath + delegate:self.delegate]; + if (!tempFilePath) { + DDLogError(@"%@ attachment could not be encrypted.", self.logTag); + OWSFail(@"%@ attachment could not be encrypted: %@", self.logTag, self.attachmentFilePath); + return; + } + self.tempFilePath = tempFilePath; +} + +@end + +#pragma mark - + +@interface OWSBackupImport () + +@property (nonatomic, weak) id delegate; + +@property (nonatomic, nullable) YapDatabaseConnection *srcDBConnection; + +@property (nonatomic, nullable) YapDatabaseConnection *dstDBConnection; + +// Indicates that the backup succeeded, failed or was cancelled. +@property (atomic) BOOL isComplete; + +@property (nonatomic, nullable) OWSBackupStorage *backupStorage; + +@property (nonatomic, nullable) NSData *databaseKeySpec; + +@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask; + +@property (nonatomic) NSMutableArray *databaseFilePaths; +// A map of "record name"-to-"file name". +@property (nonatomic) NSMutableDictionary *databaseRecordMap; + +// A map of "attachment id"-to-"local file path". +@property (nonatomic) NSMutableDictionary *attachmentFilePathMap; +// A map of "record name"-to-"file relative path". +@property (nonatomic) NSMutableDictionary *attachmentRecordMap; + +@property (nonatomic, nullable) NSString *manifestFilePath; +@property (nonatomic, nullable) NSString *manifestRecordName; + +@property (nonatomic) NSString *importDirPath; + +@end + +#pragma mark - + +@implementation OWSBackupImport + +- (instancetype)initWithDelegate:(id)delegate + primaryStorage:(OWSPrimaryStorage *)primaryStorage +{ + self = [super init]; + + if (!self) { + return self; + } + + OWSAssert(primaryStorage); + OWSAssert([OWSStorage isStorageReady]); + + self.delegate = delegate; + _srcDBConnection = primaryStorage.newDatabaseConnection; + + return self; +} + +- (void)dealloc +{ + // Surface memory leaks by logging the deallocation. + DDLogVerbose(@"Dealloc: %@", self.class); + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (self.importDirPath) { + [OWSFileSystem deleteFileIfExists:self.importDirPath]; + } +} + +- (void)startAsync +{ + OWSAssertIsOnMainThread(); + + DDLogInfo(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + self.backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; + + [self updateProgressWithDescription:nil progress:nil]; + + __weak OWSBackupImport *weakSelf = self; + [OWSBackupAPI checkCloudKitAccessWithCompletion:^(BOOL hasAccess) { + if (hasAccess) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [weakSelf start]; + }); + } + }]; +} + +- (void)start +{ + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CONFIGURATION", + @"Indicates that the backup import is being configured.") + progress:nil]; + + __weak OWSBackupImport *weakSelf = self; + [self configureImport:^(BOOL success) { + if (!success) { + [self failWithErrorDescription: + NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", + @"Error indicating the a backup import could not import the user's data.")]; + return; + } + + if (self.isComplete) { + return; + } + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_EXPORT", + @"Indicates that the backup import data is being imported.") + progress:nil]; + if (![self importDatabase]) { + [self failWithErrorDescription: + NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", + @"Error indicating the a backup import could not import the user's data.")]; + return; + } + if (self.isComplete) { + return; + } + [self saveToCloud:^(NSError *_Nullable saveError) { + if (saveError) { + [weakSelf failWithError:saveError]; + return; + } + [self cleanUpCloud:^(NSError *_Nullable cleanUpError) { + if (cleanUpError) { + [weakSelf failWithError:cleanUpError]; + return; + } + [weakSelf succeed]; + }]; + }]; + }]; +} + +- (void)configureImport:(OWSBackupImportBoolCompletion)completion +{ + OWSAssert(completion); + + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + NSString *temporaryDirectory = NSTemporaryDirectory(); + self.importDirPath = [temporaryDirectory stringByAppendingString:[NSUUID UUID].UUIDString]; + NSString *importDatabaseDirPath = [self.importDirPath stringByAppendingPathComponent:@"Database"]; + self.databaseKeySpec = [Randomness generateRandomBytes:(int)kSQLCipherKeySpecLength]; + + if (![OWSFileSystem ensureDirectoryExists:self.importDirPath]) { + OWSProdLogAndFail(@"%@ Could not create importDirPath.", self.logTag); + return completion(NO); + } + if (![OWSFileSystem ensureDirectoryExists:importDatabaseDirPath]) { + OWSProdLogAndFail(@"%@ Could not create importDatabaseDirPath.", self.logTag); + return completion(NO); + } + if (!self.databaseKeySpec) { + OWSProdLogAndFail(@"%@ Could not create databaseKeySpec.", self.logTag); + return completion(NO); + } + __weak OWSBackupImport *weakSelf = self; + BackupStorageKeySpecBlock keySpecBlock = ^{ + return weakSelf.databaseKeySpec; + }; + self.backupStorage = + [[OWSBackupStorage alloc] initBackupStorageWithDatabaseDirPath:importDatabaseDirPath keySpecBlock:keySpecBlock]; + if (!self.backupStorage) { + OWSProdLogAndFail(@"%@ Could not create backupStorage.", self.logTag); + return completion(NO); + } + _dstDBConnection = self.backupStorage.newDatabaseConnection; + if (!self.dstDBConnection) { + OWSProdLogAndFail(@"%@ Could not create dstDBConnection.", self.logTag); + return completion(NO); + } + + // TODO: Do we really need to run these registrations on the main thread? + dispatch_async(dispatch_get_main_queue(), ^{ + [self.backupStorage runSyncRegistrations]; + [self.backupStorage runAsyncRegistrationsWithCompletion:^{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + completion(YES); + }); + }]; + }); +} + +- (BOOL)importDatabase +{ + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + __block unsigned long long copiedThreads = 0; + __block unsigned long long copiedInteractions = 0; + __block unsigned long long copiedEntities = 0; + __block unsigned long long copiedAttachments = 0; + + self.attachmentFilePathMap = [NSMutableDictionary new]; + + [self.srcDBConnection readWithBlock:^(YapDatabaseReadTransaction *srcTransaction) { + [self.dstDBConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *dstTransaction) { + // Copy threads. + [srcTransaction + enumerateKeysAndObjectsInCollection:[TSThread collection] + usingBlock:^(NSString *key, id object, BOOL *stop) { + if (self.isComplete) { + *stop = YES; + return; + } + if (![object isKindOfClass:[TSThread class]]) { + OWSProdLogAndFail( + @"%@ unexpected class: %@", self.logTag, [object class]); + return; + } + TSThread *thread = object; + [thread saveWithTransaction:dstTransaction]; + copiedThreads++; + copiedEntities++; + }]; + + // Copy interactions. + [srcTransaction + enumerateKeysAndObjectsInCollection:[TSInteraction collection] + usingBlock:^(NSString *key, id object, BOOL *stop) { + if (self.isComplete) { + *stop = YES; + return; + } + if (![object isKindOfClass:[TSInteraction class]]) { + OWSProdLogAndFail( + @"%@ unexpected class: %@", self.logTag, [object class]); + return; + } + // Ignore disappearing messages. + if ([object isKindOfClass:[TSMessage class]]) { + TSMessage *message = object; + if (message.isExpiringMessage) { + return; + } + } + TSInteraction *interaction = object; + // Ignore dynamic interactions. + if (interaction.isDynamicInteraction) { + return; + } + [interaction saveWithTransaction:dstTransaction]; + copiedInteractions++; + copiedEntities++; + }]; + + // Copy attachments. + [srcTransaction + enumerateKeysAndObjectsInCollection:[TSAttachmentStream collection] + usingBlock:^(NSString *key, id object, BOOL *stop) { + if (self.isComplete) { + *stop = YES; + return; + } + if (![object isKindOfClass:[TSAttachment class]]) { + OWSProdLogAndFail( + @"%@ unexpected class: %@", self.logTag, [object class]); + return; + } + if ([object isKindOfClass:[TSAttachmentStream class]]) { + TSAttachmentStream *attachmentStream = object; + NSString *_Nullable filePath = attachmentStream.filePath; + if (filePath) { + OWSAssert(attachmentStream.uniqueId.length > 0); + self.attachmentFilePathMap[attachmentStream.uniqueId] = filePath; + } + } + TSAttachment *attachment = object; + [attachment saveWithTransaction:dstTransaction]; + copiedAttachments++; + copiedEntities++; + }]; + }]; + }]; + + // TODO: Should we do a database checkpoint? + + DDLogInfo(@"%@ copiedThreads: %llu", self.logTag, copiedThreads); + DDLogInfo(@"%@ copiedMessages: %llu", self.logTag, copiedInteractions); + DDLogInfo(@"%@ copiedEntities: %llu", self.logTag, copiedEntities); + DDLogInfo(@"%@ copiedAttachments: %llu", self.logTag, copiedAttachments); + + [self.backupStorage logFileSizes]; + + // Capture the list of files to save. + self.databaseFilePaths = [@[ + self.backupStorage.databaseFilePath, + self.backupStorage.databaseFilePath_WAL, + self.backupStorage.databaseFilePath_SHM, + ] mutableCopy]; + + // Close the database. + self.dstDBConnection = nil; + self.backupStorage = nil; + + return YES; +} + +- (void)saveToCloud:(OWSBackupImportCompletion)completion +{ + OWSAssert(completion); + + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + self.databaseRecordMap = [NSMutableDictionary new]; + self.attachmentRecordMap = [NSMutableDictionary new]; + + [self saveNextFileToCloud:completion]; +} + +- (void)saveNextFileToCloud:(OWSBackupImportCompletion)completion +{ + OWSAssert(completion); + + if (self.isComplete) { + return; + } + + CGFloat progress + = (self.databaseRecordMap.count / (CGFloat)(self.databaseRecordMap.count + self.databaseFilePaths.count)); + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_UPLOAD", + @"Indicates that the backup import data is being uploaded.") + progress:@(progress)]; + + __weak OWSBackupImport *weakSelf = self; + + if (self.databaseFilePaths.count > 0) { + NSString *filePath = self.databaseFilePaths.lastObject; + [self.databaseFilePaths removeLastObject]; + // Database files are encrypted and can be safely stored unencrypted in the cloud. + // TODO: Security review. + [OWSBackupAPI saveEphemeralDatabaseFileToCloudWithFileUrl:[NSURL fileURLWithPath: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), ^{ + OWSBackupImport *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf.databaseRecordMap[recordName] = [filePath lastPathComponent]; + [strongSelf saveNextFileToCloud:completion]; + }); + } + failure:^(NSError *error) { + // Database files are critical so any error uploading them is unrecoverable. + completion(error); + }]; + return; + } + + if (self.attachmentFilePathMap.count > 0) { + NSString *attachmentId = self.attachmentFilePathMap.allKeys.lastObject; + NSString *attachmentFilePath = self.attachmentFilePathMap[attachmentId]; + [self.attachmentFilePathMap removeObjectForKey:attachmentId]; + + // OWSAttachmentImport is used to lazily write an encrypted copy of the + // attachment to disk. + OWSAttachmentImport *attachmentImport = [OWSAttachmentImport new]; + attachmentImport.delegate = self.delegate; + attachmentImport.importDirPath = self.importDirPath; + attachmentImport.attachmentId = attachmentId; + attachmentImport.attachmentFilePath = attachmentFilePath; + + [OWSBackupAPI savePersistentFileOnceToCloudWithFileId:attachmentId + fileUrlBlock:^{ + [attachmentImport prepareForUpload]; + if (attachmentImport.tempFilePath.length < 1) { + DDLogError(@"%@ attachment import missing temp file path", self.logTag); + return (NSURL *)nil; + } + if (attachmentImport.relativeFilePath.length < 1) { + DDLogError(@"%@ attachment import missing relative file path", self.logTag); + return (NSURL *)nil; + } + return [NSURL fileURLWithPath:attachmentImport.tempFilePath]; + } + success:^(NSString *recordName) { + // Ensure that we continue to work off the main thread. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + OWSBackupImport *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf.attachmentRecordMap[recordName] = attachmentImport.relativeFilePath; + [strongSelf saveNextFileToCloud: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), ^{ + // Attachment files are non-critical so any error uploading them is recoverable. + [weakSelf saveNextFileToCloud:completion]; + }); + }]; + return; + } + + if (!self.manifestFilePath) { + if (![self writeManifestFile]) { + completion(OWSErrorWithCodeDescription(OWSErrorCodeImportBackupFailed, + NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", + @"Error indicating the a backup import could not import the user's data."))); + return; + } + OWSAssert(self.manifestFilePath); + + [OWSBackupAPI upsertManifestFileToCloudWithFileUrl:[NSURL fileURLWithPath:self.manifestFilePath] + success:^(NSString *recordName) { + // Ensure that we continue to work off the main thread. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + OWSBackupImport *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf.manifestRecordName = recordName; + [strongSelf saveNextFileToCloud:completion]; + }); + } + failure:^(NSError *error) { + // The manifest file is critical so any error uploading them is unrecoverable. + completion(error); + }]; + return; + } + + // All files have been saved to the cloud. + completion(nil); +} + +- (BOOL)writeManifestFile +{ + OWSAssert(self.databaseRecordMap.count > 0); + OWSAssert(self.attachmentRecordMap); + OWSAssert(self.importDirPath.length > 0); + OWSAssert(self.databaseKeySpec.length > 0); + + NSDictionary *json = @{ + @"database_files" : self.databaseRecordMap, + @"attachment_files" : self.attachmentRecordMap, + // JSON doesn't support byte arrays. + @"database_key_spec" : self.databaseKeySpec.base64EncodedString, + }; + NSError *error; + NSData *_Nullable jsonData = + [NSJSONSerialization dataWithJSONObject:json options:NSJSONWritingPrettyPrinted error:&error]; + if (!jsonData || error) { + OWSProdLogAndFail(@"%@ error encoding manifest file: %@", self.logTag, error); + return NO; + } + // TODO: Encrypt the manifest. + self.manifestFilePath = [self.importDirPath stringByAppendingPathComponent:@"manifest.json"]; + if (![jsonData writeToFile:self.manifestFilePath atomically:YES]) { + OWSProdLogAndFail(@"%@ error writing manifest file: %@", self.logTag, error); + return NO; + } + return YES; +} + +- (void)cleanUpCloud:(OWSBackupImportCompletion)completion +{ + OWSAssert(completion); + + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", + @"Indicates that the cloud is being cleaned up.") + progress:nil]; + + // Now that our backup import has successfully completed, + // we try to clean up the cloud. We can safely delete any + // records not involved in this backup import. + NSMutableSet *activeRecordNames = [NSMutableSet new]; + + OWSAssert(self.databaseRecordMap.count > 0); + [activeRecordNames addObjectsFromArray:self.databaseRecordMap.allKeys]; + + OWSAssert(self.attachmentRecordMap); + [activeRecordNames addObjectsFromArray:self.attachmentRecordMap.allKeys]; + + OWSAssert(self.manifestRecordName.length > 0); + [activeRecordNames addObject:self.manifestRecordName]; + + __weak OWSBackupImport *weakSelf = self; + [OWSBackupAPI fetchAllRecordNamesWithSuccess:^(NSArray *recordNames) { + // Ensure that we continue to work off the main thread. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSMutableSet *obsoleteRecordNames = [NSMutableSet new]; + [obsoleteRecordNames addObjectsFromArray:recordNames]; + [obsoleteRecordNames minusSet:activeRecordNames]; + + DDLogVerbose(@"%@ recordNames: %zd - activeRecordNames: %zd = obsoleteRecordNames: %zd", + self.logTag, + recordNames.count, + 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), ^{ + // Cloud cleanup is non-critical so any error is recoverable. + completion(nil); + }); + }]; +} + +- (void)deleteRecordsFromCloud:(NSMutableArray *)obsoleteRecordNames + deletedCount:(NSUInteger)deletedCount + completion:(OWSBackupImportCompletion)completion +{ + OWSAssert(obsoleteRecordNames); + OWSAssert(completion); + + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + if (obsoleteRecordNames.count < 1) { + // No more records to delete; cleanup is complete. + completion(nil); + return; + } + + CGFloat progress = (obsoleteRecordNames.count / (CGFloat)(obsoleteRecordNames.count + deletedCount)); + [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", + @"Indicates that the cloud is being cleaned up.") + progress:@(progress)]; + + NSString *recordName = obsoleteRecordNames.lastObject; + [obsoleteRecordNames removeLastObject]; + + __weak OWSBackupImport *weakSelf = self; + [OWSBackupAPI deleteRecordFromCloudWithRecordName:recordName + 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 + 1 + 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 + 1 + completion:completion]; + }); + }]; +} + +#pragma mark - + +- (void)cancel +{ + OWSAssertIsOnMainThread(); + + self.isComplete = YES; +} + +- (void)succeed +{ + DDLogInfo(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.isComplete) { + return; + } + self.isComplete = YES; + [self.delegate backupImportDidSucceed:self]; + }); + // TODO: +} + +- (void)failWithErrorDescription:(NSString *)description +{ + [self failWithError:OWSErrorWithCodeDescription(OWSErrorCodeImportBackupFailed, description)]; +} + +- (void)failWithError:(NSError *)error +{ + OWSProdLogAndFail(@"%@ %s %@", self.logTag, __PRETTY_FUNCTION__, error); + + // TODO: + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.isComplete) { + return; + } + self.isComplete = YES; + [self.delegate backupImportDidFail:self error:error]; + }); +} + +- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress +{ + DDLogInfo(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.isComplete) { + return; + } + [self.delegate backupImportDidUpdate:self description:description progress:progress]; + }); +} + +#pragma mark - Encryption + ++ (nullable NSString *)encryptAsTempFile:(NSString *)srcFilePath + importDirPath:(NSString *)importDirPath + delegate:(id)delegate +{ + OWSAssert(srcFilePath.length > 0); + OWSAssert(importDirPath.length > 0); + OWSAssert(delegate); + + // TODO: Encrypt the file using self.delegate.backupKey; + NSData *_Nullable backupKey = [delegate backupKey]; + OWSAssert(backupKey); + + NSString *dstFilePath = [importDirPath stringByAppendingPathComponent:[NSUUID UUID].UUIDString]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *error; + BOOL success = [fileManager copyItemAtPath:srcFilePath toPath:dstFilePath error:&error]; + if (!success || error) { + OWSProdLogAndFail(@"%@ error writing encrypted file: %@", self.logTag, error); + return nil; + } + return dstFilePath; +} + +@end + +NS_ASSUME_NONNULL_END