commit
e0cecc09e2
|
@ -27,7 +27,6 @@ DerivedData
|
|||
*.ipa
|
||||
*.xcuserstate
|
||||
Index/
|
||||
Session-Turn-Server
|
||||
|
||||
# CocoaPods
|
||||
Pods
|
||||
|
|
2
Podfile
2
Podfile
|
@ -10,6 +10,8 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'CryptoSwift'
|
||||
pod 'Sodium', '~> 0.9.1'
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||
pod 'GoogleWebRTC'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
||||
target 'Session' do
|
||||
pod 'AFNetworking'
|
||||
|
|
10
Podfile.lock
10
Podfile.lock
|
@ -21,6 +21,7 @@ PODS:
|
|||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
- GoogleWebRTC (1.1.31999)
|
||||
- Mantle (2.1.0):
|
||||
- Mantle/extobjc (= 2.1.0)
|
||||
- Mantle/extobjc (2.1.0)
|
||||
|
@ -43,6 +44,7 @@ PODS:
|
|||
- SignalCoreKit (1.0.0):
|
||||
- CocoaLumberjack
|
||||
- OpenSSL-Universal
|
||||
- SocketRocket (0.5.1)
|
||||
- Sodium (0.9.1)
|
||||
- SQLCipher (4.5.0):
|
||||
- SQLCipher/standard (= 4.5.0)
|
||||
|
@ -123,6 +125,7 @@ DEPENDENCIES:
|
|||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
|
||||
- GoogleWebRTC
|
||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
||||
- NVActivityIndicatorView
|
||||
- PromiseKit
|
||||
|
@ -130,6 +133,7 @@ DEPENDENCIES:
|
|||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (~> 0.9.1)
|
||||
- SwiftProtobuf (~> 1.5.0)
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
|
@ -141,12 +145,14 @@ SPEC REPOS:
|
|||
- AFNetworking
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- GoogleWebRTC
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
- PureLayout
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- Sodium
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
|
@ -189,6 +195,7 @@ SPEC CHECKSUMS:
|
|||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
GoogleWebRTC: b39a78c4f5cc6b0323415b9233db03a2faa7b0f0
|
||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
|
@ -197,6 +204,7 @@ SPEC CHECKSUMS:
|
|||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
|
||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
|
@ -204,6 +212,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 19ce2820c263e8f3c114817f7ca2da73a9382b6a
|
||||
PODFILE CHECKSUM: 610d1ebc4e559cf746dc3ae0ae7c78b011373d4c
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,175 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
public class BackupRestoreViewController: OWSTableViewController {
|
||||
|
||||
private var hasBegunImport = false
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var backup: OWSBackup {
|
||||
return AppEnvironment.shared.backup
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
override public func loadView() {
|
||||
super.loadView()
|
||||
|
||||
navigationItem.title = NSLocalizedString("SETTINGS_BACKUP", comment: "Label for the backup view in app settings.")
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didPressCancelButton))
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(backupStateDidChange),
|
||||
name: NSNotification.Name(NSNotificationNameBackupStateDidChange),
|
||||
object: nil)
|
||||
|
||||
updateTableContents()
|
||||
}
|
||||
|
||||
private func updateTableContents() {
|
||||
if hasBegunImport {
|
||||
updateProgressContents()
|
||||
} else {
|
||||
updateDecisionContents()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDecisionContents() {
|
||||
let contents = OWSTableContents()
|
||||
|
||||
let section = OWSTableSection()
|
||||
|
||||
section.headerTitle = NSLocalizedString("BACKUP_RESTORE_DECISION_TITLE", comment: "Label for the backup restore decision section.")
|
||||
|
||||
section.add(OWSTableItem.actionItem(withText: NSLocalizedString("CHECK_FOR_BACKUP_DO_NOT_RESTORE",
|
||||
comment: "The label for the 'do not restore backup' button."), actionBlock: { [weak self] in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
strongSelf.cancelAndDismiss()
|
||||
}))
|
||||
section.add(OWSTableItem.actionItem(withText: NSLocalizedString("CHECK_FOR_BACKUP_RESTORE",
|
||||
comment: "The label for the 'restore backup' button."), actionBlock: { [weak self] in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
strongSelf.startImport()
|
||||
}))
|
||||
|
||||
contents.addSection(section)
|
||||
self.contents = contents
|
||||
}
|
||||
|
||||
private var progressFormatter: NumberFormatter = {
|
||||
let numberFormatter = NumberFormatter()
|
||||
numberFormatter.numberStyle = .percent
|
||||
numberFormatter.maximumFractionDigits = 0
|
||||
numberFormatter.multiplier = 1
|
||||
return numberFormatter
|
||||
}()
|
||||
|
||||
private func updateProgressContents() {
|
||||
let contents = OWSTableContents()
|
||||
|
||||
let section = OWSTableSection()
|
||||
|
||||
section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_STATUS", comment: "Label for the backup restore status."), accessoryText: NSStringForBackupImportState(backup.backupImportState)))
|
||||
|
||||
if backup.backupImportState == .inProgress {
|
||||
if let backupImportDescription = backup.backupImportDescription {
|
||||
section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_DESCRIPTION", comment: "Label for the backup restore description."), accessoryText: backupImportDescription))
|
||||
}
|
||||
|
||||
if let backupImportProgress = backup.backupImportProgress {
|
||||
let progressInt = backupImportProgress.floatValue * 100
|
||||
if let progressString = progressFormatter.string(from: NSNumber(value: progressInt)) {
|
||||
section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_PROGRESS", comment: "Label for the backup restore progress."), accessoryText: progressString))
|
||||
} else {
|
||||
owsFailDebug("Could not format progress: \(progressInt)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contents.addSection(section)
|
||||
self.contents = contents
|
||||
|
||||
// TODO: Add cancel button.
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
@objc
|
||||
private func didPressCancelButton(sender: UIButton) {
|
||||
Logger.info("")
|
||||
|
||||
// TODO: Cancel import.
|
||||
|
||||
cancelAndDismiss()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func cancelAndDismiss() {
|
||||
Logger.info("")
|
||||
|
||||
backup.setHasPendingRestoreDecision(false)
|
||||
|
||||
showHomeView()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func startImport() {
|
||||
Logger.info("")
|
||||
|
||||
hasBegunImport = true
|
||||
|
||||
backup.tryToImport()
|
||||
}
|
||||
|
||||
private func showHomeView() {
|
||||
// In production, this view will never be presented in a modal.
|
||||
// During testing (debug UI, etc.), it may be a modal.
|
||||
let isModal = navigationController?.presentingViewController != nil
|
||||
if isModal {
|
||||
dismiss(animated: true, completion: {
|
||||
SignalApp.shared().showHomeView()
|
||||
})
|
||||
} else {
|
||||
SignalApp.shared().showHomeView()
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@objc func backupStateDidChange() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.verbose("backup.backupImportState: \(NSStringForBackupImportState(backup.backupImportState))")
|
||||
Logger.flush()
|
||||
|
||||
if backup.backupImportState == .succeeded {
|
||||
backup.setHasPendingRestoreDecision(false)
|
||||
|
||||
showHomeView()
|
||||
} else {
|
||||
updateTableContents()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .portrait
|
||||
}
|
||||
}
|
|
@ -1,105 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const NSNotificationNameBackupStateDidChange;
|
||||
|
||||
typedef void (^OWSBackupBoolBlock)(BOOL value);
|
||||
typedef void (^OWSBackupStringListBlock)(NSArray<NSString *> *value);
|
||||
typedef void (^OWSBackupErrorBlock)(NSError *error);
|
||||
|
||||
typedef NS_ENUM(NSUInteger, OWSBackupState) {
|
||||
// Has never backed up, not trying to backup yet.
|
||||
OWSBackupState_Idle = 0,
|
||||
// Backing up.
|
||||
OWSBackupState_InProgress,
|
||||
// Last backup failed.
|
||||
OWSBackupState_Failed,
|
||||
// Last backup succeeded.
|
||||
OWSBackupState_Succeeded,
|
||||
};
|
||||
|
||||
NSString *NSStringForBackupExportState(OWSBackupState state);
|
||||
NSString *NSStringForBackupImportState(OWSBackupState state);
|
||||
|
||||
NSArray<NSString *> *MiscCollectionsToBackup(void);
|
||||
|
||||
NSError *OWSBackupErrorWithDescription(NSString *description);
|
||||
|
||||
@class AnyPromise;
|
||||
@class OWSBackupIO;
|
||||
@class TSAttachmentPointer;
|
||||
@class TSThread;
|
||||
@class YapDatabaseConnection;
|
||||
|
||||
@interface OWSBackup : NSObject
|
||||
|
||||
- (instancetype)init NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
+ (instancetype)sharedManager NS_SWIFT_NAME(shared());
|
||||
|
||||
#pragma mark - Backup Export
|
||||
|
||||
@property (atomic, 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)isFeatureEnabled;
|
||||
|
||||
- (BOOL)isBackupEnabled;
|
||||
- (void)setIsBackupEnabled:(BOOL)value;
|
||||
|
||||
- (BOOL)hasPendingRestoreDecision;
|
||||
- (void)setHasPendingRestoreDecision:(BOOL)value;
|
||||
|
||||
- (void)tryToExportBackup;
|
||||
- (void)cancelExportBackup;
|
||||
|
||||
#pragma mark - Backup Import
|
||||
|
||||
@property (atomic, 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)allRecipientIdsWithManifestsInCloud:(OWSBackupStringListBlock)success failure:(OWSBackupErrorBlock)failure;
|
||||
|
||||
- (AnyPromise *)ensureCloudKitAccess __attribute__((warn_unused_result));
|
||||
|
||||
- (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;
|
||||
- (void)cancelImportBackup;
|
||||
|
||||
- (void)logBackupRecords;
|
||||
- (void)clearAllCloudKitRecords;
|
||||
|
||||
- (void)logBackupMetadataCache:(YapDatabaseConnection *)dbConnection;
|
||||
|
||||
#pragma mark - Lazy Restore
|
||||
|
||||
- (NSArray<NSString *> *)attachmentRecordNamesForLazyRestore;
|
||||
|
||||
- (NSArray<NSString *> *)attachmentIdsForLazyRestore;
|
||||
|
||||
- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachment backupIO:(OWSBackupIO *)backupIO __attribute__((warn_unused_result));
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,912 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackup.h"
|
||||
#import "OWSBackupExportJob.h"
|
||||
#import "OWSBackupIO.h"
|
||||
#import "OWSBackupImportJob.h"
|
||||
#import "Session-Swift.h"
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SignalCoreKit/Randomness.h>
|
||||
#import <SessionMessagingKit/OWSIdentityManager.h>
|
||||
#import <SessionMessagingKit/YapDatabaseConnection+OWS.h>
|
||||
|
||||
@import CloudKit;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *const NSNotificationNameBackupStateDidChange = @"NSNotificationNameBackupStateDidChange";
|
||||
|
||||
NSString *const OWSPrimaryStorage_OWSBackupCollection = @"OWSPrimaryStorage_OWSBackupCollection";
|
||||
NSString *const OWSBackup_IsBackupEnabledKey = @"OWSBackup_IsBackupEnabledKey";
|
||||
NSString *const OWSBackup_LastExportSuccessDateKey = @"OWSBackup_LastExportSuccessDateKey";
|
||||
NSString *const OWSBackup_LastExportFailureDateKey = @"OWSBackup_LastExportFailureDateKey";
|
||||
NSString *const OWSBackupErrorDomain = @"OWSBackupErrorDomain";
|
||||
|
||||
NSString *NSStringForBackupExportState(OWSBackupState state)
|
||||
{
|
||||
switch (state) {
|
||||
case OWSBackupState_Idle:
|
||||
return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_IDLE", @"Indicates that app is not backing up.");
|
||||
case OWSBackupState_InProgress:
|
||||
return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_IN_PROGRESS", @"Indicates that app is backing up.");
|
||||
case OWSBackupState_Failed:
|
||||
return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_FAILED", @"Indicates that the last backup failed.");
|
||||
case OWSBackupState_Succeeded:
|
||||
return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_SUCCEEDED", @"Indicates that the last backup succeeded.");
|
||||
}
|
||||
}
|
||||
|
||||
NSString *NSStringForBackupImportState(OWSBackupState state)
|
||||
{
|
||||
switch (state) {
|
||||
case OWSBackupState_Idle:
|
||||
return NSLocalizedString(@"SETTINGS_BACKUP_IMPORT_STATUS_IDLE", @"Indicates that app is not restoring up.");
|
||||
case OWSBackupState_InProgress:
|
||||
return NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_IMPORT_STATUS_IN_PROGRESS", @"Indicates that app is restoring up.");
|
||||
case OWSBackupState_Failed:
|
||||
return NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_IMPORT_STATUS_FAILED", @"Indicates that the last backup restore failed.");
|
||||
case OWSBackupState_Succeeded:
|
||||
return NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_IMPORT_STATUS_SUCCEEDED", @"Indicates that the last backup restore succeeded.");
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSString *> *MiscCollectionsToBackup(void)
|
||||
{
|
||||
return @[
|
||||
kOWSBlockingManager_BlockListCollection,
|
||||
OWSUserProfile.collection,
|
||||
SSKIncrementingIdFinder.collectionName,
|
||||
OWSPreferencesSignalDatabaseCollection,
|
||||
];
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@property (nonatomic, readonly) YapDatabaseConnection *dbConnection;
|
||||
|
||||
// This property should only be accessed on the main thread.
|
||||
@property (nonatomic, nullable) OWSBackupExportJob *backupExportJob;
|
||||
|
||||
// This property should only be accessed on the main thread.
|
||||
@property (nonatomic, nullable) OWSBackupImportJob *backupImportJob;
|
||||
|
||||
@property (nonatomic, nullable) NSString *backupExportDescription;
|
||||
@property (nonatomic, nullable) NSNumber *backupExportProgress;
|
||||
|
||||
@property (nonatomic, nullable) NSString *backupImportDescription;
|
||||
@property (nonatomic, nullable) NSNumber *backupImportProgress;
|
||||
|
||||
@property (atomic) OWSBackupState backupExportState;
|
||||
@property (atomic) OWSBackupState backupImportState;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackup
|
||||
|
||||
@synthesize dbConnection = _dbConnection;
|
||||
|
||||
+ (instancetype)sharedManager
|
||||
{
|
||||
OWSAssertDebug(AppEnvironment.shared.backup);
|
||||
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
self.backupExportState = OWSBackupState_Idle;
|
||||
self.backupImportState = OWSBackupState_Idle;
|
||||
|
||||
OWSSingletonAssert();
|
||||
|
||||
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
|
||||
[self setup];
|
||||
}];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)setup
|
||||
{
|
||||
if (!OWSBackup.isFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
[OWSBackupAPI setup];
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(applicationDidBecomeActive:)
|
||||
name:OWSApplicationDidBecomeActiveNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
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
|
||||
// starting the backup.
|
||||
//
|
||||
// TODO: Make this period longer.
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
[self ensureBackupExportState];
|
||||
});
|
||||
}
|
||||
|
||||
- (YapDatabaseConnection *)dbConnection
|
||||
{
|
||||
@synchronized(self) {
|
||||
if (!_dbConnection) {
|
||||
_dbConnection = self.primaryStorage.newDatabaseConnection;
|
||||
}
|
||||
return _dbConnection;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (OWSPrimaryStorage *)primaryStorage
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.primaryStorage);
|
||||
|
||||
return SSKEnvironment.shared.primaryStorage;
|
||||
}
|
||||
|
||||
- (TSAccountManager *)tsAccountManager
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
|
||||
|
||||
return SSKEnvironment.shared.tsAccountManager;
|
||||
}
|
||||
|
||||
+ (BOOL)isFeatureEnabled
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
#pragma mark - Backup Export
|
||||
|
||||
- (void)tryToExportBackup
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(!self.backupExportJob);
|
||||
|
||||
if (!self.canBackupExport) {
|
||||
// TODO: Offer a reason in the UI.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.tsAccountManager.isRegisteredAndReady) {
|
||||
OWSFailDebug(@"Can't backup; not registered and ready.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable recipientId = self.tsAccountManager.localNumber;
|
||||
if (recipientId.length < 1) {
|
||||
OWSFailDebug(@"Can't backup; missing recipientId.");
|
||||
return;
|
||||
}
|
||||
|
||||
// In development, make sure there's no export or import in progress.
|
||||
[self.backupExportJob cancel];
|
||||
self.backupExportJob = nil;
|
||||
[self.backupImportJob cancel];
|
||||
self.backupImportJob = nil;
|
||||
|
||||
self.backupExportState = OWSBackupState_InProgress;
|
||||
|
||||
self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId];
|
||||
[self.backupExportJob start];
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
- (void)cancelExportBackup
|
||||
{
|
||||
[self.backupExportJob cancel];
|
||||
self.backupExportJob = nil;
|
||||
|
||||
[self ensureBackupExportState];
|
||||
}
|
||||
|
||||
- (void)setLastExportSuccessDate:(NSDate *)value
|
||||
{
|
||||
OWSAssertDebug(value);
|
||||
|
||||
[self.dbConnection setDate:value
|
||||
forKey:OWSBackup_LastExportSuccessDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
}
|
||||
|
||||
- (nullable NSDate *)lastExportSuccessDate
|
||||
{
|
||||
return [self.dbConnection dateForKey:OWSBackup_LastExportSuccessDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
}
|
||||
|
||||
- (void)setLastExportFailureDate:(NSDate *)value
|
||||
{
|
||||
OWSAssertDebug(value);
|
||||
|
||||
[self.dbConnection setDate:value
|
||||
forKey:OWSBackup_LastExportFailureDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
}
|
||||
|
||||
|
||||
- (nullable NSDate *)lastExportFailureDate
|
||||
{
|
||||
return [self.dbConnection dateForKey:OWSBackup_LastExportFailureDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
}
|
||||
|
||||
- (BOOL)isBackupEnabled
|
||||
{
|
||||
return [self.dbConnection boolForKey:OWSBackup_IsBackupEnabledKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection
|
||||
defaultValue:NO];
|
||||
}
|
||||
|
||||
- (void)setIsBackupEnabled:(BOOL)value
|
||||
{
|
||||
[self.dbConnection setBool:value
|
||||
forKey:OWSBackup_IsBackupEnabledKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
|
||||
if (!value) {
|
||||
[self.dbConnection removeObjectForKey:OWSBackup_LastExportSuccessDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
[self.dbConnection removeObjectForKey:OWSBackup_LastExportFailureDateKey
|
||||
inCollection:OWSPrimaryStorage_OWSBackupCollection];
|
||||
}
|
||||
|
||||
[self postDidChangeNotification];
|
||||
|
||||
[self ensureBackupExportState];
|
||||
}
|
||||
|
||||
- (BOOL)hasPendingRestoreDecision
|
||||
{
|
||||
return [self.tsAccountManager hasPendingBackupRestoreDecision];
|
||||
}
|
||||
|
||||
- (void)setHasPendingRestoreDecision:(BOOL)value
|
||||
{
|
||||
[self.tsAccountManager setHasPendingBackupRestoreDecision:value];
|
||||
}
|
||||
|
||||
- (BOOL)canBackupExport
|
||||
{
|
||||
if (!self.isBackupEnabled) {
|
||||
return NO;
|
||||
}
|
||||
if (UIApplication.sharedApplication.applicationState != UIApplicationStateActive) {
|
||||
// Don't start backups when app is in the background.
|
||||
return NO;
|
||||
}
|
||||
if (![self.tsAccountManager isRegisteredAndReady]) {
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)shouldHaveBackupExport
|
||||
{
|
||||
if (!self.canBackupExport) {
|
||||
return NO;
|
||||
}
|
||||
if (self.backupExportJob) {
|
||||
// If there's already a job in progress, let it complete.
|
||||
return YES;
|
||||
}
|
||||
NSDate *_Nullable lastExportSuccessDate = self.lastExportSuccessDate;
|
||||
NSDate *_Nullable lastExportFailureDate = self.lastExportFailureDate;
|
||||
// Wait N hours before retrying after a success.
|
||||
const NSTimeInterval kRetryAfterSuccess = 24 * kHourInterval;
|
||||
if (lastExportSuccessDate && fabs(lastExportSuccessDate.timeIntervalSinceNow) < kRetryAfterSuccess) {
|
||||
return NO;
|
||||
}
|
||||
// Wait N hours before retrying after a failure.
|
||||
const NSTimeInterval kRetryAfterFailure = 6 * kHourInterval;
|
||||
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.backupImportJob) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
// TODO: There's other conditions that affect this decision,
|
||||
// e.g. Reachability, wifi v. cellular, etc.
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)ensureBackupExportState
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
if (!OWSBackup.isFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CurrentAppContext().isMainApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.tsAccountManager.isRegisteredAndReady) {
|
||||
OWSLogError(@"Can't backup; not registered and ready.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable recipientId = self.tsAccountManager.localNumber;
|
||||
if (recipientId.length < 1) {
|
||||
OWSFailDebug(@"Can't backup; missing recipientId.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Start or abort a backup export if neccessary.
|
||||
if (!self.shouldHaveBackupExport && self.backupExportJob) {
|
||||
[self.backupExportJob cancel];
|
||||
self.backupExportJob = nil;
|
||||
} else if (self.shouldHaveBackupExport && !self.backupExportJob) {
|
||||
self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId];
|
||||
[self.backupExportJob start];
|
||||
}
|
||||
|
||||
// Update the state flag.
|
||||
OWSBackupState backupExportState = OWSBackupState_Idle;
|
||||
if (self.backupExportJob) {
|
||||
backupExportState = OWSBackupState_InProgress;
|
||||
} else {
|
||||
NSDate *_Nullable lastExportSuccessDate = self.lastExportSuccessDate;
|
||||
NSDate *_Nullable lastExportFailureDate = self.lastExportFailureDate;
|
||||
if (!lastExportSuccessDate && !lastExportFailureDate) {
|
||||
backupExportState = OWSBackupState_Idle;
|
||||
} else if (lastExportSuccessDate && lastExportFailureDate) {
|
||||
backupExportState = ([lastExportSuccessDate isAfterDate:lastExportFailureDate] ? OWSBackupState_Succeeded
|
||||
: OWSBackupState_Failed);
|
||||
} else if (lastExportSuccessDate) {
|
||||
backupExportState = OWSBackupState_Succeeded;
|
||||
} else if (lastExportFailureDate) {
|
||||
backupExportState = OWSBackupState_Failed;
|
||||
} else {
|
||||
OWSFailDebug(@"unexpected condition.");
|
||||
}
|
||||
}
|
||||
|
||||
BOOL stateDidChange = self.backupExportState != backupExportState;
|
||||
self.backupExportState = backupExportState;
|
||||
if (stateDidChange) {
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Backup Import
|
||||
|
||||
- (void)allRecipientIdsWithManifestsInCloud:(OWSBackupStringListBlock)success failure:(OWSBackupErrorBlock)failure
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
[OWSBackupAPI
|
||||
allRecipientIdsWithManifestsInCloudWithSuccess:^(NSArray<NSString *> *recipientIds) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
success(recipientIds);
|
||||
});
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
failure(error);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (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();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
if (!OWSBackup.isFeatureEnabled) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
success(NO);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void (^failWithUnexpectedError)(void) = ^{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSError *error =
|
||||
[NSError errorWithDomain:OWSBackupErrorDomain
|
||||
code:1
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey : NSLocalizedString(@"BACKUP_UNEXPECTED_ERROR",
|
||||
@"Error shown when backup fails due to an unexpected error.")
|
||||
}];
|
||||
failure(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();
|
||||
}
|
||||
|
||||
[[OWSBackupAPI ensureCloudKitAccessObjc]
|
||||
.thenInBackground(^{
|
||||
return [OWSBackupAPI checkForManifestInCloudObjcWithRecipientId:recipientId];
|
||||
})
|
||||
.then(^(NSNumber *value) {
|
||||
success(value.boolValue);
|
||||
})
|
||||
.catch(^(NSError *error) {
|
||||
failure(error);
|
||||
}) retainUntilComplete];
|
||||
}
|
||||
|
||||
- (void)tryToImportBackup
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(!self.backupImportJob);
|
||||
|
||||
if (!self.tsAccountManager.isRegisteredAndReady) {
|
||||
OWSLogError(@"Can't restore backup; not registered and ready.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable recipientId = self.tsAccountManager.localNumber;
|
||||
if (recipientId.length < 1) {
|
||||
OWSLogError(@"Can't restore backup; missing recipientId.");
|
||||
return;
|
||||
}
|
||||
|
||||
// In development, make sure there's no export or import in progress.
|
||||
[self.backupExportJob cancel];
|
||||
self.backupExportJob = nil;
|
||||
[self.backupImportJob cancel];
|
||||
self.backupImportJob = nil;
|
||||
|
||||
self.backupImportState = OWSBackupState_InProgress;
|
||||
|
||||
self.backupImportJob = [[OWSBackupImportJob alloc] initWithDelegate:self recipientId:recipientId];
|
||||
[self.backupImportJob start];
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
- (void)cancelImportBackup
|
||||
{
|
||||
[self.backupImportJob cancel];
|
||||
self.backupImportJob = nil;
|
||||
|
||||
self.backupImportState = OWSBackupState_Idle;
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self ensureBackupExportState];
|
||||
}
|
||||
|
||||
- (void)registrationStateDidChange
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self ensureBackupExportState];
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
- (void)ckAccountChanged
|
||||
{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self ensureBackupExportState];
|
||||
|
||||
[self postDidChangeNotification];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - OWSBackupJobDelegate
|
||||
|
||||
// We use a delegate method to avoid storing this key in memory.
|
||||
- (nullable NSData *)backupEncryptionKey
|
||||
{
|
||||
// TODO: Use actual encryption key.
|
||||
return [@"temp" dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
- (void)backupJobDidSucceed:(OWSBackupJob *)backupJob
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@".");
|
||||
|
||||
if (self.backupImportJob == backupJob) {
|
||||
self.backupImportJob = nil;
|
||||
|
||||
self.backupImportState = OWSBackupState_Succeeded;
|
||||
} else if (self.backupExportJob == backupJob) {
|
||||
self.backupExportJob = nil;
|
||||
|
||||
[self setLastExportSuccessDate:[NSDate new]];
|
||||
|
||||
[self ensureBackupExportState];
|
||||
} else {
|
||||
OWSLogWarn(@"obsolete job succeeded: %@", [backupJob class]);
|
||||
return;
|
||||
}
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
- (void)backupJobDidFail:(OWSBackupJob *)backupJob error:(NSError *)error
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@": %@", error);
|
||||
|
||||
if (self.backupImportJob == backupJob) {
|
||||
self.backupImportJob = nil;
|
||||
|
||||
self.backupImportState = OWSBackupState_Failed;
|
||||
} else if (self.backupExportJob == backupJob) {
|
||||
self.backupExportJob = nil;
|
||||
|
||||
[self setLastExportFailureDate:[NSDate new]];
|
||||
|
||||
[self ensureBackupExportState];
|
||||
} else {
|
||||
OWSLogInfo(@"obsolete backup job failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
|
||||
- (void)backupJobDidUpdate:(OWSBackupJob *)backupJob
|
||||
description:(nullable NSString *)description
|
||||
progress:(nullable NSNumber *)progress
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
// TODO: Should we consolidate this state?
|
||||
BOOL didChange;
|
||||
if (self.backupImportJob == backupJob) {
|
||||
didChange = !([NSObject isNullableObject:self.backupImportDescription equalTo:description] &&
|
||||
[NSObject isNullableObject:self.backupImportProgress equalTo:progress]);
|
||||
|
||||
self.backupImportDescription = description;
|
||||
self.backupImportProgress = progress;
|
||||
} else if (self.backupExportJob == backupJob) {
|
||||
didChange = !([NSObject isNullableObject:self.backupExportDescription equalTo:description] &&
|
||||
[NSObject isNullableObject:self.backupExportProgress equalTo:progress]);
|
||||
|
||||
self.backupExportDescription = description;
|
||||
self.backupExportProgress = progress;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (didChange) {
|
||||
[self postDidChangeNotification];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)logBackupRecords
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
if (!self.tsAccountManager.isRegisteredAndReady) {
|
||||
OWSLogError(@"Can't interact with backup; not registered and ready.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable recipientId = self.tsAccountManager.localNumber;
|
||||
if (recipientId.length < 1) {
|
||||
OWSLogError(@"Can't interact with backup; missing recipientId.");
|
||||
return;
|
||||
}
|
||||
|
||||
[OWSBackupAPI fetchAllRecordNamesWithRecipientId:recipientId
|
||||
success:^(NSArray<NSString *> *recordNames) {
|
||||
for (NSString *recordName in [recordNames sortedArrayUsingSelector:@selector(compare:)]) {
|
||||
OWSLogInfo(@"\t %@", recordName);
|
||||
}
|
||||
OWSLogInfo(@"record count: %zd", recordNames.count);
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
OWSLogError(@"Failed to retrieve backup records: %@", error);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)clearAllCloudKitRecords
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
if (!self.tsAccountManager.isRegisteredAndReady) {
|
||||
OWSLogError(@"Can't interact with backup; not registered and ready.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable recipientId = self.tsAccountManager.localNumber;
|
||||
if (recipientId.length < 1) {
|
||||
OWSLogError(@"Can't interact with backup; missing recipientId.");
|
||||
return;
|
||||
}
|
||||
|
||||
[OWSBackupAPI fetchAllRecordNamesWithRecipientId:recipientId
|
||||
success:^(NSArray<NSString *> *recordNames) {
|
||||
if (recordNames.count < 1) {
|
||||
OWSLogInfo(@"No CloudKit records found to clear.");
|
||||
return;
|
||||
}
|
||||
[OWSBackupAPI deleteRecordsFromCloudWithRecordNames:recordNames
|
||||
success:^{
|
||||
OWSLogInfo(@"Clear all CloudKit records succeeded.");
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
OWSLogError(@"Clear all CloudKit records failed: %@.", error);
|
||||
}];
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
OWSLogError(@"Failed to retrieve CloudKit records: %@", error);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Restore
|
||||
|
||||
- (NSArray<NSString *> *)attachmentRecordNamesForLazyRestore
|
||||
{
|
||||
NSMutableArray<NSString *> *recordNames = [NSMutableArray new];
|
||||
[self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
||||
id ext = [transaction ext:TSLazyRestoreAttachmentsDatabaseViewExtensionName];
|
||||
if (!ext) {
|
||||
OWSFailDebug(@"Could not load database view.");
|
||||
return;
|
||||
}
|
||||
|
||||
[ext enumerateKeysAndObjectsInGroup:TSLazyRestoreAttachmentsGroup
|
||||
usingBlock:^(
|
||||
NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) {
|
||||
if (![object isKindOfClass:[TSAttachmentPointer class]]) {
|
||||
OWSFailDebug(
|
||||
@"Unexpected object: %@ in collection:%@", [object class], collection);
|
||||
return;
|
||||
}
|
||||
TSAttachmentPointer *attachmentPointer = object;
|
||||
if (!attachmentPointer.lazyRestoreFragment) {
|
||||
OWSFailDebug(
|
||||
@"Invalid object: %@ in collection:%@", [object class], collection);
|
||||
return;
|
||||
}
|
||||
[recordNames addObject:attachmentPointer.lazyRestoreFragment.recordName];
|
||||
}];
|
||||
}];
|
||||
return recordNames;
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)attachmentIdsForLazyRestore
|
||||
{
|
||||
NSMutableArray<NSString *> *attachmentIds = [NSMutableArray new];
|
||||
[self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
||||
id ext = [transaction ext:TSLazyRestoreAttachmentsDatabaseViewExtensionName];
|
||||
if (!ext) {
|
||||
OWSFailDebug(@"Could not load database view.");
|
||||
return;
|
||||
}
|
||||
|
||||
[ext enumerateKeysInGroup:TSLazyRestoreAttachmentsGroup
|
||||
usingBlock:^(NSString *collection, NSString *key, NSUInteger index, BOOL *stop) {
|
||||
[attachmentIds addObject:key];
|
||||
}];
|
||||
}];
|
||||
return attachmentIds;
|
||||
}
|
||||
|
||||
- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachment backupIO:(OWSBackupIO *)backupIO
|
||||
{
|
||||
OWSAssertDebug(attachment);
|
||||
OWSAssertDebug(backupIO);
|
||||
|
||||
OWSBackupFragment *_Nullable lazyRestoreFragment = attachment.lazyRestoreFragment;
|
||||
if (!lazyRestoreFragment) {
|
||||
OWSLogError(@"Attachment missing lazy restore metadata.");
|
||||
return
|
||||
[AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Attachment missing lazy restore metadata.")];
|
||||
}
|
||||
if (lazyRestoreFragment.recordName.length < 1 || lazyRestoreFragment.encryptionKey.length < 1) {
|
||||
OWSLogError(@"Incomplete lazy restore metadata.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Incomplete lazy restore metadata.")];
|
||||
}
|
||||
|
||||
// Use a predictable file path so that multiple "import backup" attempts
|
||||
// will leverage successful file downloads from previous attempts.
|
||||
//
|
||||
// TODO: This will also require imports using a predictable jobTempDirPath.
|
||||
NSString *tempFilePath = [backupIO generateTempFilePath];
|
||||
|
||||
return [OWSBackupAPI downloadFileFromCloudObjcWithRecordName:lazyRestoreFragment.recordName
|
||||
toFileUrl:[NSURL fileURLWithPath:tempFilePath]]
|
||||
.thenInBackground(^{
|
||||
return [self lazyRestoreAttachment:attachment
|
||||
backupIO:backupIO
|
||||
encryptedFilePath:tempFilePath
|
||||
encryptionKey:lazyRestoreFragment.encryptionKey];
|
||||
});
|
||||
}
|
||||
|
||||
- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachmentPointer
|
||||
backupIO:(OWSBackupIO *)backupIO
|
||||
encryptedFilePath:(NSString *)encryptedFilePath
|
||||
encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(attachmentPointer);
|
||||
OWSAssertDebug(backupIO);
|
||||
OWSAssertDebug(encryptedFilePath.length > 0);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
NSData *_Nullable data = [NSData dataWithContentsOfFile:encryptedFilePath];
|
||||
if (!data) {
|
||||
OWSLogError(@"Could not load encrypted file.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not load encrypted file.")];
|
||||
}
|
||||
|
||||
NSString *decryptedFilePath = [backupIO generateTempFilePath];
|
||||
|
||||
@autoreleasepool {
|
||||
if (![backupIO decryptFileAsFile:encryptedFilePath dstFilePath:decryptedFilePath encryptionKey:encryptionKey]) {
|
||||
OWSLogError(@"Could not load decrypt file.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not load decrypt file.")];
|
||||
}
|
||||
}
|
||||
|
||||
TSAttachmentStream *stream = [[TSAttachmentStream alloc] initWithPointer:attachmentPointer];
|
||||
|
||||
NSString *attachmentFilePath = stream.originalFilePath;
|
||||
if (attachmentFilePath.length < 1) {
|
||||
OWSLogError(@"Attachment has invalid file path.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Attachment has invalid file path.")];
|
||||
}
|
||||
|
||||
NSString *attachmentDirPath = [attachmentFilePath stringByDeletingLastPathComponent];
|
||||
if (![OWSFileSystem ensureDirectoryExists:attachmentDirPath]) {
|
||||
OWSLogError(@"Couldn't create directory for attachment file.");
|
||||
return [AnyPromise
|
||||
promiseWithValue:OWSBackupErrorWithDescription(@"Couldn't create directory for attachment file.")];
|
||||
}
|
||||
|
||||
if (![OWSFileSystem deleteFileIfExists:attachmentFilePath]) {
|
||||
OWSFailDebug(@"Couldn't delete existing file at attachment path.");
|
||||
return [AnyPromise
|
||||
promiseWithValue:OWSBackupErrorWithDescription(@"Couldn't delete existing file at attachment path.")];
|
||||
}
|
||||
|
||||
NSError *error;
|
||||
BOOL success =
|
||||
[NSFileManager.defaultManager moveItemAtPath:decryptedFilePath toPath:attachmentFilePath error:&error];
|
||||
if (!success || error) {
|
||||
OWSLogError(@"Attachment file could not be restored: %@.", error);
|
||||
return [AnyPromise promiseWithValue:error];
|
||||
}
|
||||
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
// This should overwrite the attachment pointer with an attachment stream.
|
||||
[stream saveWithTransaction:transaction];
|
||||
}];
|
||||
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
- (void)logBackupMetadataCache:(YapDatabaseConnection *)dbConnection
|
||||
{
|
||||
OWSLogInfo(@"");
|
||||
|
||||
[dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
||||
[transaction enumerateKeysAndObjectsInCollection:[OWSBackupFragment collection]
|
||||
usingBlock:^(NSString *key, OWSBackupFragment *fragment, BOOL *stop) {
|
||||
OWSLogVerbose(@"fragment: %@, %@, %lu, %@, %@, %@, %@",
|
||||
key,
|
||||
fragment.recordName,
|
||||
(unsigned long)fragment.encryptionKey.length,
|
||||
fragment.relativeFilePath,
|
||||
fragment.attachmentId,
|
||||
fragment.downloadFilePath,
|
||||
fragment.uncompressedDataLength);
|
||||
}];
|
||||
OWSLogVerbose(@"Number of fragments: %lu",
|
||||
(unsigned long)[transaction numberOfKeysInCollection:[OWSBackupFragment collection]]);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Notifications
|
||||
|
||||
- (void)postDidChangeNotification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[[NSNotificationCenter defaultCenter] postNotificationNameAsync:NSNotificationNameBackupStateDidChange
|
||||
object:nil
|
||||
userInfo:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,740 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
import CloudKit
|
||||
import PromiseKit
|
||||
|
||||
// We don't worry about atomic writes. Each backup export
|
||||
// will diff against last successful backup.
|
||||
//
|
||||
// Note that all of our CloudKit records are immutable.
|
||||
// "Persistent" records are only uploaded once.
|
||||
// "Ephemeral" records are always uploaded to a new record name.
|
||||
@objc public class OWSBackupAPI: NSObject {
|
||||
|
||||
// If we change the record types, we need to ensure indices
|
||||
// are configured properly in the CloudKit dashboard.
|
||||
//
|
||||
// TODO: Change the record types when we ship to production.
|
||||
static let signalBackupRecordType = "signalBackup"
|
||||
static let manifestRecordNameSuffix = "manifest"
|
||||
static let payloadKey = "payload"
|
||||
static let maxRetries = 5
|
||||
|
||||
private class func database() -> CKDatabase {
|
||||
let myContainer = CKContainer.default()
|
||||
let privateDatabase = myContainer.privateCloudDatabase
|
||||
return privateDatabase
|
||||
}
|
||||
|
||||
private class func invalidServiceResponseError() -> Error {
|
||||
return OWSErrorWithCodeDescription(.backupFailure,
|
||||
NSLocalizedString("BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE",
|
||||
comment: "Error indicating that the app received an invalid response from CloudKit."))
|
||||
}
|
||||
|
||||
// MARK: - Upload
|
||||
|
||||
@objc
|
||||
public class func recordNameForTestFile(recipientId: String) -> String {
|
||||
return "\(recordNamePrefix(forRecipientId: recipientId))test-\(NSUUID().uuidString)"
|
||||
}
|
||||
|
||||
// "Ephemeral" files are specific to this backup export and will always need to
|
||||
// be saved. For example, a complete image of the database is exported each time.
|
||||
// We wouldn't want to overwrite previous images until the entire backup export is
|
||||
// complete.
|
||||
@objc
|
||||
public class func recordNameForEphemeralFile(recipientId: String,
|
||||
label: String) -> String {
|
||||
return "\(recordNamePrefix(forRecipientId: recipientId))ephemeral-\(label)-\(NSUUID().uuidString)"
|
||||
}
|
||||
|
||||
// "Persistent" files may be shared between backup export; they should only be saved
|
||||
// once. For example, attachment files should only be uploaded once. Subsequent
|
||||
// backups can reuse the same record.
|
||||
@objc
|
||||
public class func recordNameForPersistentFile(recipientId: String,
|
||||
fileId: String) -> String {
|
||||
return "\(recordNamePrefix(forRecipientId: recipientId))persistentFile-\(fileId)"
|
||||
}
|
||||
|
||||
// "Persistent" files may be shared between backup export; they should only be saved
|
||||
// once. For example, attachment files should only be uploaded once. Subsequent
|
||||
// backups can reuse the same record.
|
||||
@objc
|
||||
public class func recordNameForManifest(recipientId: String) -> String {
|
||||
return "\(recordNamePrefix(forRecipientId: recipientId))\(manifestRecordNameSuffix)"
|
||||
}
|
||||
|
||||
private class func isManifest(recordName: String) -> Bool {
|
||||
return recordName.hasSuffix(manifestRecordNameSuffix)
|
||||
}
|
||||
|
||||
private class func recordNamePrefix(forRecipientId recipientId: String) -> String {
|
||||
return "\(recipientId)-"
|
||||
}
|
||||
|
||||
private class func recipientId(forRecordName recordName: String) -> String? {
|
||||
let recipientIds = self.recipientIds(forRecordNames: [recordName])
|
||||
guard let recipientId = recipientIds.first else {
|
||||
return nil
|
||||
}
|
||||
return recipientId
|
||||
}
|
||||
|
||||
private static var recordNamePrefixRegex = {
|
||||
return try! NSRegularExpression(pattern: "^(\\+[0-9]+)\\-")
|
||||
}()
|
||||
|
||||
private class func recipientIds(forRecordNames recordNames: [String]) -> [String] {
|
||||
var recipientIds = [String]()
|
||||
for recordName in recordNames {
|
||||
let regex = recordNamePrefixRegex
|
||||
guard let match: NSTextCheckingResult = regex.firstMatch(in: recordName, options: [], range: NSRange(location: 0, length: recordName.utf16.count)) else {
|
||||
Logger.warn("no match: \(recordName)")
|
||||
continue
|
||||
}
|
||||
guard match.numberOfRanges > 0 else {
|
||||
// Match must include first group.
|
||||
Logger.warn("invalid match: \(recordName)")
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
let recipientId = (recordName as NSString).substring(with: firstRange) as String
|
||||
recipientIds.append(recipientId)
|
||||
}
|
||||
return recipientIds
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func record(forFileUrl fileUrl: URL,
|
||||
recordName: String) -> CKRecord {
|
||||
let recordType = signalBackupRecordType
|
||||
let recordID = CKRecord.ID(recordName: recordName)
|
||||
let record = CKRecord(recordType: recordType, recordID: recordID)
|
||||
let asset = CKAsset(fileURL: fileUrl)
|
||||
record[payloadKey] = asset
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func saveRecordsToCloudObjc(records: [CKRecord]) -> AnyPromise {
|
||||
return AnyPromise(saveRecordsToCloud(records: records))
|
||||
}
|
||||
|
||||
public class func saveRecordsToCloud(records: [CKRecord]) -> Promise<Void> {
|
||||
|
||||
// CloudKit's internal limit is 400, but I haven't found a constant for this.
|
||||
let kMaxBatchSize = 100
|
||||
return records.chunked(by: kMaxBatchSize).reduce(Promise.value(())) { (promise, batch) -> Promise<Void> in
|
||||
return promise.then(on: .global()) {
|
||||
saveRecordsToCloud(records: batch, remainingRetries: maxRetries)
|
||||
}.done {
|
||||
Logger.verbose("Saved batch: \(batch.count)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class func saveRecordsToCloud(records: [CKRecord],
|
||||
remainingRetries: Int) -> Promise<Void> {
|
||||
|
||||
let recordNames = records.map { (record) in
|
||||
return record.recordID.recordName
|
||||
}
|
||||
Logger.verbose("recordNames[\(recordNames.count)] \(recordNames[0..<10])...")
|
||||
|
||||
return Promise { resolver in
|
||||
let saveOperation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
|
||||
saveOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, _, error) in
|
||||
|
||||
let retry = {
|
||||
// Only retry records which didn't already succeed.
|
||||
var savedRecordNames = [String]()
|
||||
if let savedRecords = savedRecords {
|
||||
savedRecordNames = savedRecords.map { (record) in
|
||||
return record.recordID.recordName
|
||||
}
|
||||
}
|
||||
let retryRecords = records.filter({ (record) in
|
||||
return !savedRecordNames.contains(record.recordID.recordName)
|
||||
})
|
||||
|
||||
saveRecordsToCloud(records: retryRecords,
|
||||
remainingRetries: remainingRetries - 1)
|
||||
.done { _ in
|
||||
resolver.fulfill(())
|
||||
}.catch { (error) in
|
||||
resolver.reject(error)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
let outcome = outcomeForCloudKitError(error: error,
|
||||
remainingRetries: remainingRetries,
|
||||
label: "Save Records[\(recordNames.count)]")
|
||||
switch outcome {
|
||||
case .success:
|
||||
resolver.fulfill(())
|
||||
case .failureDoNotRetry(let outcomeError):
|
||||
resolver.reject(outcomeError)
|
||||
case .failureRetryAfterDelay(let retryDelay):
|
||||
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
|
||||
retry()
|
||||
})
|
||||
case .failureRetryWithoutDelay:
|
||||
DispatchQueue.global().async {
|
||||
retry()
|
||||
}
|
||||
case .unknownItem:
|
||||
owsFailDebug("unexpected CloudKit response.")
|
||||
resolver.reject(invalidServiceResponseError())
|
||||
}
|
||||
}
|
||||
saveOperation.isAtomic = false
|
||||
saveOperation.savePolicy = .allKeys
|
||||
|
||||
// TODO: use perRecordProgressBlock and perRecordCompletionBlock.
|
||||
// open var perRecordProgressBlock: ((CKRecord, Double) -> Void)?
|
||||
// open var perRecordCompletionBlock: ((CKRecord, Error?) -> Void)?
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
@objc
|
||||
public class func deleteRecordsFromCloud(recordNames: [String],
|
||||
success: @escaping () -> Void,
|
||||
failure: @escaping (Error) -> Void) {
|
||||
deleteRecordsFromCloud(recordNames: recordNames,
|
||||
remainingRetries: maxRetries,
|
||||
success: success,
|
||||
failure: failure)
|
||||
}
|
||||
|
||||
private class func deleteRecordsFromCloud(recordNames: [String],
|
||||
remainingRetries: Int,
|
||||
success: @escaping () -> Void,
|
||||
failure: @escaping (Error) -> Void) {
|
||||
|
||||
let recordIDs = recordNames.map { CKRecord.ID(recordName: $0) }
|
||||
let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs)
|
||||
deleteOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in
|
||||
|
||||
let outcome = outcomeForCloudKitError(error: error,
|
||||
remainingRetries: remainingRetries,
|
||||
label: "Delete Records")
|
||||
switch outcome {
|
||||
case .success:
|
||||
success()
|
||||
case .failureDoNotRetry(let outcomeError):
|
||||
failure(outcomeError)
|
||||
case .failureRetryAfterDelay(let retryDelay):
|
||||
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
|
||||
deleteRecordsFromCloud(recordNames: recordNames,
|
||||
remainingRetries: remainingRetries - 1,
|
||||
success: success,
|
||||
failure: failure)
|
||||
})
|
||||
case .failureRetryWithoutDelay:
|
||||
DispatchQueue.global().async {
|
||||
deleteRecordsFromCloud(recordNames: recordNames,
|
||||
remainingRetries: remainingRetries - 1,
|
||||
success: success,
|
||||
failure: failure)
|
||||
}
|
||||
case .unknownItem:
|
||||
owsFailDebug("unexpected CloudKit response.")
|
||||
failure(invalidServiceResponseError())
|
||||
}
|
||||
}
|
||||
database().add(deleteOperation)
|
||||
}
|
||||
|
||||
// MARK: - Exists?
|
||||
|
||||
private class func checkForFileInCloud(recordName: String,
|
||||
remainingRetries: Int) -> Promise<CKRecord?> {
|
||||
|
||||
Logger.verbose("checkForFileInCloud \(recordName)")
|
||||
|
||||
let (promise, resolver) = Promise<CKRecord?>.pending()
|
||||
|
||||
let recordId = CKRecord.ID(recordName: recordName)
|
||||
let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ])
|
||||
// Don't download the file; we're just using the fetch to check whether or
|
||||
// not this record already exists.
|
||||
fetchOperation.desiredKeys = []
|
||||
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
|
||||
|
||||
let outcome = outcomeForCloudKitError(error: error,
|
||||
remainingRetries: remainingRetries,
|
||||
label: "Check for Record")
|
||||
switch outcome {
|
||||
case .success:
|
||||
guard let record = record else {
|
||||
owsFailDebug("missing fetching record.")
|
||||
resolver.reject(invalidServiceResponseError())
|
||||
return
|
||||
}
|
||||
// Record found.
|
||||
resolver.fulfill(record)
|
||||
case .failureDoNotRetry(let outcomeError):
|
||||
resolver.reject(outcomeError)
|
||||
case .failureRetryAfterDelay(let retryDelay):
|
||||
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
|
||||
checkForFileInCloud(recordName: recordName,
|
||||
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)
|
||||
.done { (record) in
|
||||
resolver.fulfill(record)
|
||||
}.catch { (error) in
|
||||
resolver.reject(error)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
case .unknownItem:
|
||||
// Record not found.
|
||||
resolver.fulfill(nil)
|
||||
}
|
||||
}
|
||||
database().add(fetchOperation)
|
||||
return promise
|
||||
}
|
||||
|
||||
@objc
|
||||
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)
|
||||
return checkForFileInCloud(recordName: recordName,
|
||||
remainingRetries: maxRetries)
|
||||
.map { (record) in
|
||||
return record != nil
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func allRecipientIdsWithManifestsInCloud(success: @escaping ([String]) -> Void,
|
||||
failure: @escaping (Error) -> Void) {
|
||||
|
||||
let processResults = { (recordNames: [String]) in
|
||||
DispatchQueue.global().async {
|
||||
let manifestRecordNames = recordNames.filter({ (recordName) -> Bool in
|
||||
self.isManifest(recordName: recordName)
|
||||
})
|
||||
let recipientIds = self.recipientIds(forRecordNames: manifestRecordNames)
|
||||
success(recipientIds)
|
||||
}
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true))
|
||||
// Fetch the first page of results for this query.
|
||||
fetchAllRecordNamesStep(recipientId: nil,
|
||||
query: query,
|
||||
previousRecordNames: [String](),
|
||||
cursor: nil,
|
||||
remainingRetries: maxRetries,
|
||||
success: processResults,
|
||||
failure: failure)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func fetchAllRecordNames(recipientId: String,
|
||||
success: @escaping ([String]) -> Void,
|
||||
failure: @escaping (Error) -> Void) {
|
||||
|
||||
let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true))
|
||||
// Fetch the first page of results for this query.
|
||||
fetchAllRecordNamesStep(recipientId: recipientId,
|
||||
query: query,
|
||||
previousRecordNames: [String](),
|
||||
cursor: nil,
|
||||
remainingRetries: maxRetries,
|
||||
success: success,
|
||||
failure: failure)
|
||||
}
|
||||
|
||||
private class func fetchAllRecordNamesStep(recipientId: String?,
|
||||
query: CKQuery,
|
||||
previousRecordNames: [String],
|
||||
cursor: CKQueryOperation.Cursor?,
|
||||
remainingRetries: Int,
|
||||
success: @escaping ([String]) -> Void,
|
||||
failure: @escaping (Error) -> Void) {
|
||||
|
||||
var allRecordNames = previousRecordNames
|
||||
|
||||
let queryOperation = CKQueryOperation(query: query)
|
||||
// If this isn't the first page of results for this query, resume
|
||||
// where we left off.
|
||||
queryOperation.cursor = cursor
|
||||
// Don't download the file; we're just using the query to get a list of record names.
|
||||
queryOperation.desiredKeys = []
|
||||
queryOperation.recordFetchedBlock = { (record) in
|
||||
assert(record.recordID.recordName.count > 0)
|
||||
|
||||
let recordName = record.recordID.recordName
|
||||
|
||||
if let recipientId = recipientId {
|
||||
let prefix = recordNamePrefix(forRecipientId: recipientId)
|
||||
guard recordName.hasPrefix(prefix) else {
|
||||
Logger.info("Ignoring record: \(recordName)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
allRecordNames.append(recordName)
|
||||
}
|
||||
queryOperation.queryCompletionBlock = { (cursor, error) in
|
||||
|
||||
let outcome = outcomeForCloudKitError(error: error,
|
||||
remainingRetries: remainingRetries,
|
||||
label: "Fetch All Records")
|
||||
switch outcome {
|
||||
case .success:
|
||||
if let cursor = cursor {
|
||||
Logger.verbose("fetching more record names \(allRecordNames.count).")
|
||||
// There are more pages of results, continue fetching.
|
||||
fetchAllRecordNamesStep(recipientId: recipientId,
|
||||
query: query,
|
||||
previousRecordNames: allRecordNames,
|
||||
cursor: cursor,
|
||||
remainingRetries: maxRetries,
|
||||
success: success,
|
||||
failure: failure)
|
||||
return
|
||||
}
|
||||
Logger.info("fetched \(allRecordNames.count) record names.")
|
||||
success(allRecordNames)
|
||||
case .failureDoNotRetry(let outcomeError):
|
||||
failure(outcomeError)
|
||||
case .failureRetryAfterDelay(let retryDelay):
|
||||
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
|
||||
fetchAllRecordNamesStep(recipientId: recipientId,
|
||||
query: query,
|
||||
previousRecordNames: allRecordNames,
|
||||
cursor: cursor,
|
||||
remainingRetries: remainingRetries - 1,
|
||||
success: success,
|
||||
failure: failure)
|
||||
})
|
||||
case .failureRetryWithoutDelay:
|
||||
DispatchQueue.global().async {
|
||||
fetchAllRecordNamesStep(recipientId: recipientId,
|
||||
query: query,
|
||||
previousRecordNames: allRecordNames,
|
||||
cursor: cursor,
|
||||
remainingRetries: remainingRetries - 1,
|
||||
success: success,
|
||||
failure: failure)
|
||||
}
|
||||
case .unknownItem:
|
||||
owsFailDebug("unexpected CloudKit response.")
|
||||
failure(invalidServiceResponseError())
|
||||
}
|
||||
}
|
||||
database().add(queryOperation)
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
@objc
|
||||
public class func downloadManifestFromCloudObjc(recipientId: String) -> AnyPromise {
|
||||
return AnyPromise(downloadManifestFromCloud(recipientId: recipientId))
|
||||
}
|
||||
|
||||
public class func downloadManifestFromCloud(recipientId: String) -> Promise<Data> {
|
||||
|
||||
let recordName = recordNameForManifest(recipientId: recipientId)
|
||||
return downloadDataFromCloud(recordName: recordName)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func downloadDataFromCloudObjc(recordName: String) -> AnyPromise {
|
||||
return AnyPromise(downloadDataFromCloud(recordName: recordName))
|
||||
}
|
||||
|
||||
public class func downloadDataFromCloud(recordName: String) -> Promise<Data> {
|
||||
return downloadFromCloud(recordName: recordName,
|
||||
remainingRetries: maxRetries)
|
||||
.map { (asset) -> Data in
|
||||
guard let fileURL = asset.fileURL else {
|
||||
throw invalidServiceResponseError()
|
||||
}
|
||||
return try Data(contentsOf: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func downloadFileFromCloudObjc(recordName: String,
|
||||
toFileUrl: URL) -> AnyPromise {
|
||||
return AnyPromise(downloadFileFromCloud(recordName: recordName,
|
||||
toFileUrl: toFileUrl))
|
||||
}
|
||||
|
||||
public class func downloadFileFromCloud(recordName: String,
|
||||
toFileUrl: URL) -> Promise<Void> {
|
||||
|
||||
return downloadFromCloud(recordName: recordName,
|
||||
remainingRetries: maxRetries)
|
||||
.done { asset in
|
||||
guard let fileURL = asset.fileURL else {
|
||||
throw invalidServiceResponseError()
|
||||
}
|
||||
try FileManager.default.copyItem(at: fileURL, to: toFileUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// We return the CKAsset and not its fileUrl because
|
||||
// CloudKit offers no guarantees around how long it'll
|
||||
// keep around the underlying file. Presumably we can
|
||||
// defer cleanup by maintaining a strong reference to
|
||||
// the asset.
|
||||
private class func downloadFromCloud(recordName: String,
|
||||
remainingRetries: Int) -> Promise<CKAsset> {
|
||||
|
||||
Logger.verbose("downloadFromCloud \(recordName)")
|
||||
|
||||
let (promise, resolver) = Promise<CKAsset>.pending()
|
||||
|
||||
let recordId = CKRecord.ID(recordName: recordName)
|
||||
let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ])
|
||||
// Download all keys for this record.
|
||||
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
|
||||
|
||||
let outcome = outcomeForCloudKitError(error: error,
|
||||
remainingRetries: remainingRetries,
|
||||
label: "Download Record")
|
||||
switch outcome {
|
||||
case .success:
|
||||
guard let record = record else {
|
||||
Logger.error("missing fetching record.")
|
||||
resolver.reject(invalidServiceResponseError())
|
||||
return
|
||||
}
|
||||
guard let asset = record[payloadKey] as? CKAsset else {
|
||||
Logger.error("record missing payload.")
|
||||
resolver.reject(invalidServiceResponseError())
|
||||
return
|
||||
}
|
||||
resolver.fulfill(asset)
|
||||
case .failureDoNotRetry(let outcomeError):
|
||||
resolver.reject(outcomeError)
|
||||
case .failureRetryAfterDelay(let retryDelay):
|
||||
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
|
||||
downloadFromCloud(recordName: recordName,
|
||||
remainingRetries: remainingRetries - 1)
|
||||
.done { (asset) in
|
||||
resolver.fulfill(asset)
|
||||
}.catch { (error) in
|
||||
resolver.reject(error)
|
||||
}.retainUntilComplete()
|
||||
})
|
||||
case .failureRetryWithoutDelay:
|
||||
DispatchQueue.global().async {
|
||||
downloadFromCloud(recordName: recordName,
|
||||
remainingRetries: remainingRetries - 1)
|
||||
.done { (asset) in
|
||||
resolver.fulfill(asset)
|
||||
}.catch { (error) in
|
||||
resolver.reject(error)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
case .unknownItem:
|
||||
Logger.error("missing fetching record.")
|
||||
resolver.reject(invalidServiceResponseError())
|
||||
}
|
||||
}
|
||||
database().add(fetchOperation)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
// MARK: - Access
|
||||
|
||||
@objc public enum BackupError: Int, Error {
|
||||
case couldNotDetermineAccountStatus
|
||||
case noAccount
|
||||
case restrictedAccountStatus
|
||||
}
|
||||
|
||||
@objc
|
||||
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(())
|
||||
default: 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
|
||||
|
||||
private enum APIOutcome {
|
||||
case success
|
||||
case failureDoNotRetry(error:Error)
|
||||
case failureRetryAfterDelay(retryDelay: TimeInterval)
|
||||
case failureRetryWithoutDelay
|
||||
// This only applies to fetches.
|
||||
case unknownItem
|
||||
}
|
||||
|
||||
private class func outcomeForCloudKitError(error: Error?,
|
||||
remainingRetries: Int,
|
||||
label: String) -> APIOutcome {
|
||||
if let error = error as? CKError {
|
||||
if error.code == CKError.unknownItem {
|
||||
// This is not always an error for our purposes.
|
||||
Logger.verbose("\(label) unknown item.")
|
||||
return .unknownItem
|
||||
}
|
||||
|
||||
Logger.error("\(label) failed: \(error)")
|
||||
|
||||
if remainingRetries < 1 {
|
||||
Logger.verbose("\(label) no more retries.")
|
||||
return .failureDoNotRetry(error:error)
|
||||
}
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
if error.code == CKError.serverResponseLost {
|
||||
Logger.verbose("\(label) retry without delay.")
|
||||
return .failureRetryWithoutDelay
|
||||
}
|
||||
}
|
||||
|
||||
switch error {
|
||||
case CKError.requestRateLimited, CKError.serviceUnavailable, CKError.zoneBusy:
|
||||
let retryDelay = error.retryAfterSeconds ?? 3.0
|
||||
Logger.verbose("\(label) retry with delay: \(retryDelay).")
|
||||
return .failureRetryAfterDelay(retryDelay:retryDelay)
|
||||
case CKError.networkFailure:
|
||||
Logger.verbose("\(label) retry without delay.")
|
||||
return .failureRetryWithoutDelay
|
||||
default:
|
||||
Logger.verbose("\(label) unknown CKError.")
|
||||
return .failureDoNotRetry(error:error)
|
||||
}
|
||||
} else if let error = error {
|
||||
Logger.error("\(label) failed: \(error)")
|
||||
if remainingRetries < 1 {
|
||||
Logger.verbose("\(label) no more retries.")
|
||||
return .failureDoNotRetry(error:error)
|
||||
}
|
||||
Logger.verbose("\(label) unknown error.")
|
||||
return .failureDoNotRetry(error:error)
|
||||
} else {
|
||||
Logger.info("\(label) succeeded.")
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public class func setup() {
|
||||
cancelAllLongLivedOperations()
|
||||
}
|
||||
|
||||
private class func cancelAllLongLivedOperations() {
|
||||
// These APIs are only available in iOS 9.3 and later.
|
||||
guard #available(iOS 9.3, *) else {
|
||||
return
|
||||
}
|
||||
|
||||
let container = CKContainer.default()
|
||||
container.fetchAllLongLivedOperationIDs { (operationIds, error) in
|
||||
if let error = error {
|
||||
Logger.error("Could not get all long lived operations: \(error)")
|
||||
return
|
||||
}
|
||||
guard let operationIds = operationIds else {
|
||||
Logger.error("No operation ids.")
|
||||
return
|
||||
}
|
||||
|
||||
for operationId in operationIds {
|
||||
container.fetchLongLivedOperation(withID: operationId, completionHandler: { (operation, error) in
|
||||
if let error = error {
|
||||
Logger.error("Could not get long lived operation [\(operationId)]: \(error)")
|
||||
return
|
||||
}
|
||||
guard let operation = operation else {
|
||||
Logger.error("No operation.")
|
||||
return
|
||||
}
|
||||
operation.cancel()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupJob.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupExportJob : OWSBackupJob
|
||||
|
||||
- (void)start;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -1,61 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupEncryptedItem : NSObject
|
||||
|
||||
@property (nonatomic) NSString *filePath;
|
||||
|
||||
@property (nonatomic) NSData *encryptionKey;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface OWSBackupIO : NSObject
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
- (instancetype)initWithJobTempDirPath:(NSString *)jobTempDirPath;
|
||||
|
||||
- (NSString *)generateTempFilePath;
|
||||
|
||||
- (nullable NSString *)createTempFile;
|
||||
|
||||
#pragma mark - Encrypt
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath;
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath
|
||||
encryptionKey:(NSData *)encryptionKey;
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData;
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData encryptionKey:(NSData *)encryptionKey;
|
||||
|
||||
#pragma mark - Decrypt
|
||||
|
||||
- (BOOL)decryptFileAsFile:(NSString *)srcFilePath
|
||||
dstFilePath:(NSString *)dstFilePath
|
||||
encryptionKey:(NSData *)encryptionKey;
|
||||
|
||||
- (nullable NSData *)decryptFileAsData:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey;
|
||||
|
||||
- (nullable NSData *)decryptDataAsData:(NSData *)srcData encryptionKey:(NSData *)encryptionKey;
|
||||
|
||||
#pragma mark - Compression
|
||||
|
||||
- (nullable NSData *)compressData:(NSData *)srcData;
|
||||
|
||||
// I'm using the (new in iOS 9) compressionlib. One of its weaknesses is that it
|
||||
// requires you to pre-allocate output buffers during compression and decompression.
|
||||
// During decompression this is particularly tricky since there's no way to safely
|
||||
// predict how large the output will be based on the input. So, we store the
|
||||
// uncompressed size for compressed backup items.
|
||||
- (nullable NSData *)decompressData:(NSData *)srcData uncompressedDataLength:(NSUInteger)uncompressedDataLength;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,273 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupIO.h"
|
||||
#import <SignalCoreKit/Randomness.h>
|
||||
#import <SessionUtilitiesKit/OWSFileSystem.h>
|
||||
|
||||
@import Compression;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// TODO:
|
||||
static const NSUInteger kOWSBackupKeyLength = 32;
|
||||
|
||||
// LZMA algorithm significantly outperforms the other compressionlib options
|
||||
// for our database snapshots and is a widely adopted standard.
|
||||
static const compression_algorithm SignalCompressionAlgorithm = COMPRESSION_LZMA;
|
||||
|
||||
@implementation OWSBackupEncryptedItem
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface OWSBackupIO ()
|
||||
|
||||
@property (nonatomic) NSString *jobTempDirPath;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupIO
|
||||
|
||||
- (instancetype)initWithJobTempDirPath:(NSString *)jobTempDirPath
|
||||
{
|
||||
if (!(self = [super init])) {
|
||||
return self;
|
||||
}
|
||||
|
||||
self.jobTempDirPath = jobTempDirPath;
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSString *)generateTempFilePath
|
||||
{
|
||||
return [self.jobTempDirPath stringByAppendingPathComponent:[NSUUID UUID].UUIDString];
|
||||
}
|
||||
|
||||
- (nullable NSString *)createTempFile
|
||||
{
|
||||
NSString *filePath = [self generateTempFilePath];
|
||||
if (![OWSFileSystem ensureFileExists:filePath]) {
|
||||
OWSFailDebug(@"could not create temp file.");
|
||||
return nil;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
#pragma mark - Encrypt
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath
|
||||
{
|
||||
OWSAssertDebug(srcFilePath.length > 0);
|
||||
|
||||
NSData *encryptionKey = [Randomness generateRandomBytes:(int)kOWSBackupKeyLength];
|
||||
|
||||
return [self encryptFileAsTempFile:srcFilePath encryptionKey:encryptionKey];
|
||||
}
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(srcFilePath.length > 0);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
@autoreleasepool {
|
||||
if (![[NSFileManager defaultManager] fileExistsAtPath:srcFilePath]) {
|
||||
OWSFailDebug(@"Missing source file.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
// TODO: Encrypt the file without loading it into memory.
|
||||
NSData *_Nullable srcData = [NSData dataWithContentsOfFile:srcFilePath];
|
||||
if (srcData.length < 1) {
|
||||
OWSFailDebug(@"could not load file into memory for encryption.");
|
||||
return nil;
|
||||
}
|
||||
return [self encryptDataAsTempFile:srcData encryptionKey:encryptionKey];
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData
|
||||
{
|
||||
OWSAssertDebug(srcData);
|
||||
|
||||
NSData *encryptionKey = [Randomness generateRandomBytes:(int)kOWSBackupKeyLength];
|
||||
|
||||
return [self encryptDataAsTempFile:srcData encryptionKey:encryptionKey];
|
||||
}
|
||||
|
||||
- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)unencryptedData
|
||||
encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(unencryptedData);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
// TODO: Encrypt the data using key;
|
||||
NSData *encryptedData = unencryptedData;
|
||||
|
||||
NSString *_Nullable dstFilePath = [self createTempFile];
|
||||
if (!dstFilePath) {
|
||||
return nil;
|
||||
}
|
||||
NSError *error;
|
||||
BOOL success = [encryptedData writeToFile:dstFilePath options:NSDataWritingAtomic error:&error];
|
||||
if (!success || error) {
|
||||
OWSFailDebug(@"error writing encrypted data: %@", error);
|
||||
return nil;
|
||||
}
|
||||
[OWSFileSystem protectFileOrFolderAtPath:dstFilePath];
|
||||
OWSBackupEncryptedItem *item = [OWSBackupEncryptedItem new];
|
||||
item.filePath = dstFilePath;
|
||||
item.encryptionKey = encryptionKey;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Decrypt
|
||||
|
||||
- (BOOL)decryptFileAsFile:(NSString *)srcFilePath
|
||||
dstFilePath:(NSString *)dstFilePath
|
||||
encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(srcFilePath.length > 0);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
// TODO: Decrypt the file without loading it into memory.
|
||||
NSData *data = [self decryptFileAsData:srcFilePath encryptionKey:encryptionKey];
|
||||
|
||||
if (data.length < 1) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSError *error;
|
||||
BOOL success = [data writeToFile:dstFilePath options:NSDataWritingAtomic error:&error];
|
||||
if (!success || error) {
|
||||
OWSFailDebug(@"error writing decrypted data: %@", error);
|
||||
return NO;
|
||||
}
|
||||
[OWSFileSystem protectFileOrFolderAtPath:dstFilePath];
|
||||
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSData *)decryptFileAsData:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(srcFilePath.length > 0);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
if (![NSFileManager.defaultManager fileExistsAtPath:srcFilePath]) {
|
||||
OWSLogError(@"missing downloaded file.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *_Nullable srcData = [NSData dataWithContentsOfFile:srcFilePath];
|
||||
if (srcData.length < 1) {
|
||||
OWSFailDebug(@"could not load file into memory for decryption.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *_Nullable dstData = [self decryptDataAsData:srcData encryptionKey:encryptionKey];
|
||||
return dstData;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSData *)decryptDataAsData:(NSData *)encryptedData encryptionKey:(NSData *)encryptionKey
|
||||
{
|
||||
OWSAssertDebug(encryptedData);
|
||||
OWSAssertDebug(encryptionKey.length > 0);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
// TODO: Decrypt the data using key;
|
||||
NSData *unencryptedData = encryptedData;
|
||||
|
||||
return unencryptedData;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Compression
|
||||
|
||||
- (nullable NSData *)compressData:(NSData *)srcData
|
||||
{
|
||||
OWSAssertDebug(srcData);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
if (!srcData) {
|
||||
OWSFailDebug(@"missing unencrypted data.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
size_t srcLength = [srcData length];
|
||||
|
||||
// This assumes that dst will always be smaller than src.
|
||||
//
|
||||
// We slightly pad the buffer size to account for the worst case.
|
||||
size_t dstBufferLength = srcLength + 64 * 1024;
|
||||
NSMutableData *dstBufferData = [NSMutableData dataWithLength:dstBufferLength];
|
||||
if (!dstBufferData) {
|
||||
OWSFailDebug(@"Failed to allocate buffer.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
size_t dstLength = compression_encode_buffer(
|
||||
dstBufferData.mutableBytes, dstBufferLength, srcData.bytes, srcLength, NULL, SignalCompressionAlgorithm);
|
||||
NSData *compressedData = [dstBufferData subdataWithRange:NSMakeRange(0, dstLength)];
|
||||
|
||||
OWSLogVerbose(@"compressed %zd -> %zd = %0.2f",
|
||||
srcLength,
|
||||
dstLength,
|
||||
(srcLength > 0 ? (dstLength / (CGFloat)srcLength) : 0));
|
||||
|
||||
return compressedData;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSData *)decompressData:(NSData *)srcData uncompressedDataLength:(NSUInteger)uncompressedDataLength
|
||||
{
|
||||
OWSAssertDebug(srcData);
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
if (!srcData) {
|
||||
OWSFailDebug(@"missing unencrypted data.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
size_t srcLength = [srcData length];
|
||||
|
||||
// We pad the buffer to be defensive.
|
||||
size_t dstBufferLength = uncompressedDataLength + 1024;
|
||||
NSMutableData *dstBufferData = [NSMutableData dataWithLength:dstBufferLength];
|
||||
if (!dstBufferData) {
|
||||
OWSFailDebug(@"Failed to allocate buffer.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
size_t dstLength = compression_decode_buffer(
|
||||
dstBufferData.mutableBytes, dstBufferLength, srcData.bytes, srcLength, NULL, SignalCompressionAlgorithm);
|
||||
NSData *decompressedData = [dstBufferData subdataWithRange:NSMakeRange(0, dstLength)];
|
||||
OWSAssertDebug(decompressedData.length == uncompressedDataLength);
|
||||
OWSLogVerbose(@"decompressed %zd -> %zd = %0.2f",
|
||||
srcLength,
|
||||
dstLength,
|
||||
(dstLength > 0 ? (srcLength / (CGFloat)dstLength) : 0));
|
||||
|
||||
return decompressedData;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,15 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupJob.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupImportJob : OWSBackupJob
|
||||
|
||||
- (void)start;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,635 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupImportJob.h"
|
||||
#import "OWSBackupIO.h"
|
||||
#import "OWSDatabaseMigration.h"
|
||||
#import "OWSDatabaseMigrationRunner.h"
|
||||
#import "Session-Swift.h"
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SignalCoreKit/NSData+OWS.h>
|
||||
#import <SessionMessagingKit/OWSBackgroundTask.h>
|
||||
#import <SessionUtilitiesKit/OWSFileSystem.h>
|
||||
#import <SessionMessagingKit/TSAttachment.h>
|
||||
#import <SessionMessagingKit/TSMessage.h>
|
||||
#import <SessionMessagingKit/TSThread.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *const kOWSBackup_ImportDatabaseKeySpec = @"kOWSBackup_ImportDatabaseKeySpec";
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface OWSBackupImportJob ()
|
||||
|
||||
@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask;
|
||||
|
||||
@property (nonatomic) OWSBackupIO *backupIO;
|
||||
|
||||
@property (nonatomic) OWSBackupManifestContents *manifest;
|
||||
|
||||
@property (nonatomic, nullable) YapDatabaseConnection *dbConnection;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupImportJob
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (OWSPrimaryStorage *)primaryStorage
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.primaryStorage);
|
||||
|
||||
return SSKEnvironment.shared.primaryStorage;
|
||||
}
|
||||
|
||||
- (OWSProfileManager *)profileManager
|
||||
{
|
||||
return [OWSProfileManager sharedManager];
|
||||
}
|
||||
|
||||
- (TSAccountManager *)tsAccountManager
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
|
||||
|
||||
return SSKEnvironment.shared.tsAccountManager;
|
||||
}
|
||||
|
||||
- (OWSBackup *)backup
|
||||
{
|
||||
OWSAssertDebug(AppEnvironment.shared.backup);
|
||||
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
- (OWSBackupLazyRestore *)backupLazyRestore
|
||||
{
|
||||
return AppEnvironment.shared.backupLazyRestore;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (NSArray<OWSBackupFragment *> *)databaseItems
|
||||
{
|
||||
OWSAssertDebug(self.manifest);
|
||||
|
||||
return self.manifest.databaseItems;
|
||||
}
|
||||
|
||||
- (NSArray<OWSBackupFragment *> *)attachmentsItems
|
||||
{
|
||||
OWSAssertDebug(self.manifest);
|
||||
|
||||
return self.manifest.attachmentsItems;
|
||||
}
|
||||
|
||||
- (void)start
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
self.backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__];
|
||||
|
||||
self.dbConnection = self.primaryStorage.newDatabaseConnection;
|
||||
|
||||
[self updateProgressWithDescription:nil progress:nil];
|
||||
|
||||
[[self.backup ensureCloudKitAccess]
|
||||
.thenInBackground(^{
|
||||
[self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_CONFIGURATION",
|
||||
@"Indicates that the backup import is being configured.")
|
||||
progress:nil];
|
||||
|
||||
return [self configureImport];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
if (self.isComplete) {
|
||||
return
|
||||
[AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
[self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_IMPORT",
|
||||
@"Indicates that the backup import data is being imported.")
|
||||
progress:nil];
|
||||
|
||||
return [self downloadAndProcessManifestWithBackupIO:self.backupIO];
|
||||
})
|
||||
.thenInBackground(^(OWSBackupManifestContents *manifest) {
|
||||
OWSCAssertDebug(manifest.databaseItems.count > 0);
|
||||
OWSCAssertDebug(manifest.attachmentsItems);
|
||||
|
||||
self.manifest = manifest;
|
||||
|
||||
return [self downloadAndProcessImport];
|
||||
})
|
||||
.catch(^(NSError *error) {
|
||||
[self failWithErrorDescription:
|
||||
NSLocalizedString(@"BACKUP_IMPORT_ERROR_COULD_NOT_IMPORT",
|
||||
@"Error indicating the backup import could not import the user's data.")];
|
||||
}) retainUntilComplete];
|
||||
}
|
||||
|
||||
- (AnyPromise *)downloadAndProcessImport
|
||||
{
|
||||
OWSAssertDebug(self.databaseItems);
|
||||
OWSAssertDebug(self.attachmentsItems);
|
||||
|
||||
// These items should be downloaded immediately.
|
||||
NSMutableArray<OWSBackupFragment *> *allItems = [NSMutableArray new];
|
||||
[allItems addObjectsFromArray:self.databaseItems];
|
||||
|
||||
// Make a copy of the blockingItems before we add
|
||||
// the optional items.
|
||||
NSArray<OWSBackupFragment *> *blockingItems = [allItems copy];
|
||||
|
||||
// Local profile avatars are optional in the sense that if their
|
||||
// download fails, we want to proceed with the import.
|
||||
if (self.manifest.localProfileAvatarItem) {
|
||||
[allItems addObject:self.manifest.localProfileAvatarItem];
|
||||
}
|
||||
|
||||
// Attachment items can be downloaded later;
|
||||
// they will can be lazy-restored.
|
||||
[allItems addObjectsFromArray:self.attachmentsItems];
|
||||
|
||||
// Record metadata for all items, so that we can re-use them in incremental backups after the restore.
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
for (OWSBackupFragment *item in allItems) {
|
||||
[item saveWithTransaction:transaction];
|
||||
}
|
||||
}];
|
||||
|
||||
return [self downloadFilesFromCloud:blockingItems]
|
||||
.thenInBackground(^{
|
||||
return [self restoreDatabase];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
return [self ensureMigrations];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
return [self restoreLocalProfile];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
return [self restoreAttachmentFiles];
|
||||
})
|
||||
.then(^{
|
||||
// Kick off lazy restore on main thread.
|
||||
[self.backupLazyRestore clearCompleteAndRunIfNecessary];
|
||||
|
||||
// Make sure backup is enabled once we complete
|
||||
// a backup restore.
|
||||
[OWSBackup.sharedManager setIsBackupEnabled:YES];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
return [self.tsAccountManager updateAccountAttributes];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
[self succeed];
|
||||
});
|
||||
}
|
||||
|
||||
- (AnyPromise *)configureImport
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (![self ensureJobTempDir]) {
|
||||
OWSFailDebug(@"Could not create jobTempDirPath.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not create jobTempDirPath.")];
|
||||
}
|
||||
|
||||
self.backupIO = [[OWSBackupIO alloc] initWithJobTempDirPath:self.jobTempDirPath];
|
||||
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
- (AnyPromise *)downloadFilesFromCloud:(NSArray<OWSBackupFragment *> *)items
|
||||
{
|
||||
OWSAssertDebug(items.count > 0);
|
||||
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
NSUInteger recordCount = items.count;
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
if (items.count < 1) {
|
||||
// All downloads are complete; exit.
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
AnyPromise *promise = [AnyPromise promiseWithValue:@(1)];
|
||||
for (OWSBackupFragment *item in items) {
|
||||
promise = promise
|
||||
.thenInBackground(^{
|
||||
CGFloat progress
|
||||
= (recordCount > 0 ? ((recordCount - items.count) / (CGFloat)recordCount) : 0.f);
|
||||
[self updateProgressWithDescription:
|
||||
NSLocalizedString(@"BACKUP_IMPORT_PHASE_DOWNLOAD",
|
||||
@"Indicates that the backup import data is being downloaded.")
|
||||
progress:@(progress)];
|
||||
})
|
||||
.thenInBackground(^{
|
||||
return [self downloadFileFromCloud:item];
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
- (AnyPromise *)downloadFileFromCloud:(OWSBackupFragment *)item
|
||||
{
|
||||
OWSAssertDebug(item);
|
||||
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
|
||||
// TODO: Use a predictable file path so that multiple "import backup" attempts
|
||||
// will leverage successful file downloads from previous attempts.
|
||||
//
|
||||
// TODO: This will also require imports using a predictable jobTempDirPath.
|
||||
NSString *tempFilePath = [self.jobTempDirPath stringByAppendingPathComponent:item.recordName];
|
||||
|
||||
// Skip redundant file download.
|
||||
if ([NSFileManager.defaultManager fileExistsAtPath:tempFilePath]) {
|
||||
[OWSFileSystem protectFileOrFolderAtPath:tempFilePath];
|
||||
|
||||
item.downloadFilePath = tempFilePath;
|
||||
|
||||
return resolve(@(1));
|
||||
}
|
||||
|
||||
[OWSBackupAPI downloadFileFromCloudObjcWithRecordName:item.recordName
|
||||
toFileUrl:[NSURL fileURLWithPath:tempFilePath]]
|
||||
.thenInBackground(^{
|
||||
[OWSFileSystem protectFileOrFolderAtPath:tempFilePath];
|
||||
item.downloadFilePath = tempFilePath;
|
||||
|
||||
resolve(@(1));
|
||||
})
|
||||
.catchInBackground(^(NSError *error) {
|
||||
resolve(error);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (AnyPromise *)restoreLocalProfile
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
AnyPromise *promise = [AnyPromise promiseWithValue:@(1)];
|
||||
|
||||
if (self.manifest.localProfileAvatarItem) {
|
||||
promise = promise.thenInBackground(^{
|
||||
return
|
||||
[self downloadFileFromCloud:self.manifest.localProfileAvatarItem].catchInBackground(^(NSError *error) {
|
||||
OWSLogInfo(@"Ignoring error; profiles are optional: %@", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
promise = promise.thenInBackground(^{
|
||||
return [self applyLocalProfile];
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
- (AnyPromise *)applyLocalProfile
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
NSString *_Nullable localProfileName = self.manifest.localProfileName;
|
||||
UIImage *_Nullable localProfileAvatar = [self tryToLoadLocalProfileAvatar];
|
||||
|
||||
OWSLogVerbose(@"local profile name: %@, avatar: %d", localProfileName, localProfileAvatar != nil);
|
||||
|
||||
if (localProfileName.length < 1 && !localProfileAvatar) {
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
|
||||
[self.profileManager updateLocalProfileName:localProfileName
|
||||
avatarImage:localProfileAvatar
|
||||
success:^{
|
||||
resolve(@(1));
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
// Ignore errors related to local profile.
|
||||
resolve(@(1));
|
||||
}
|
||||
requiresSync:YES];
|
||||
}];
|
||||
}
|
||||
|
||||
- (nullable UIImage *)tryToLoadLocalProfileAvatar
|
||||
{
|
||||
if (!self.manifest.localProfileAvatarItem) {
|
||||
return nil;
|
||||
}
|
||||
if (!self.manifest.localProfileAvatarItem.downloadFilePath) {
|
||||
// Download of the avatar failed.
|
||||
// We can safely ignore errors related to local profile.
|
||||
OWSLogError(@"local profile avatar was not downloaded.");
|
||||
return nil;
|
||||
}
|
||||
OWSBackupFragment *item = self.manifest.localProfileAvatarItem;
|
||||
if (item.recordName.length < 1) {
|
||||
OWSFailDebug(@"item missing record name.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
@autoreleasepool {
|
||||
NSData *_Nullable data =
|
||||
[self.backupIO decryptFileAsData:item.downloadFilePath encryptionKey:item.encryptionKey];
|
||||
if (!data) {
|
||||
OWSLogError(@"could not decrypt local profile avatar.");
|
||||
// Ignore errors related to local profile.
|
||||
return nil;
|
||||
}
|
||||
// TODO: Verify that we're not compressing the profile avatar data.
|
||||
UIImage *_Nullable image = [UIImage imageWithData:data];
|
||||
if (!image) {
|
||||
OWSLogError(@"could not decrypt local profile avatar.");
|
||||
// Ignore errors related to local profile.
|
||||
return nil;
|
||||
}
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
- (AnyPromise *)restoreAttachmentFiles
|
||||
{
|
||||
OWSLogVerbose(@": %zd", self.attachmentsItems.count);
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
__block NSUInteger count = 0;
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
for (OWSBackupFragment *item in self.attachmentsItems) {
|
||||
if (self.isComplete) {
|
||||
return;
|
||||
}
|
||||
if (item.recordName.length < 1) {
|
||||
OWSLogError(@"attachment was not downloaded.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
continue;
|
||||
}
|
||||
if (item.attachmentId.length < 1) {
|
||||
OWSLogError(@"attachment missing attachment id.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
continue;
|
||||
}
|
||||
if (item.relativeFilePath.length < 1) {
|
||||
OWSLogError(@"attachment missing relative file path.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
continue;
|
||||
}
|
||||
TSAttachmentPointer *_Nullable attachment =
|
||||
[TSAttachmentPointer fetchObjectWithUniqueID:item.attachmentId transaction:transaction];
|
||||
if (!attachment) {
|
||||
OWSLogError(@"attachment to restore could not be found.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
continue;
|
||||
}
|
||||
if (![attachment isKindOfClass:[TSAttachmentPointer class]]) {
|
||||
OWSFailDebug(@"attachment has unexpected type: %@.", attachment.class);
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
continue;
|
||||
}
|
||||
[attachment markForLazyRestoreWithFragment:item transaction:transaction];
|
||||
count++;
|
||||
[self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_RESTORING_FILES",
|
||||
@"Indicates that the backup import data is being restored.")
|
||||
progress:@(count / (CGFloat)self.attachmentsItems.count)];
|
||||
}
|
||||
}];
|
||||
|
||||
OWSLogError(@"enqueued lazy restore of %zd files.", count);
|
||||
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
- (AnyPromise *)restoreDatabase
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
// Order matters here.
|
||||
NSArray<NSString *> *collectionsToRestore = @[
|
||||
[TSThread collection],
|
||||
[TSAttachment collection],
|
||||
// Interactions refer to threads and attachments,
|
||||
// so copy them afterward.
|
||||
[TSInteraction collection],
|
||||
[OWSDatabaseMigration collection],
|
||||
];
|
||||
NSMutableDictionary<NSString *, NSNumber *> *restoredEntityCounts = [NSMutableDictionary new];
|
||||
__block unsigned long long copiedEntities = 0;
|
||||
__block BOOL aborted = NO;
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
for (NSString *collection in collectionsToRestore) {
|
||||
if ([collection isEqualToString:[OWSDatabaseMigration collection]]) {
|
||||
// It's okay if there are existing migrations; we'll clear those
|
||||
// before restoring.
|
||||
continue;
|
||||
}
|
||||
if ([transaction numberOfKeysInCollection:collection] > 0) {
|
||||
OWSLogError(@"unexpected contents in database (%@).", collection);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing database contents.
|
||||
//
|
||||
// This should be safe since we only ever import into an empty database.
|
||||
//
|
||||
// Note that if the app receives a message after registering and before restoring
|
||||
// backup, it will be lost.
|
||||
//
|
||||
// Note that this will clear all migrations.
|
||||
for (NSString *collection in collectionsToRestore) {
|
||||
[transaction removeAllObjectsInCollection:collection];
|
||||
}
|
||||
|
||||
NSUInteger count = 0;
|
||||
for (OWSBackupFragment *item in self.databaseItems) {
|
||||
if (self.isComplete) {
|
||||
return;
|
||||
}
|
||||
if (item.recordName.length < 1) {
|
||||
OWSLogError(@"database snapshot was not downloaded.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
if (!item.uncompressedDataLength || item.uncompressedDataLength.unsignedIntValue < 1) {
|
||||
OWSLogError(@"database snapshot missing size.");
|
||||
// Attachment-related errors are recoverable and can be ignored.
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
count++;
|
||||
[self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_RESTORING_DATABASE",
|
||||
@"Indicates that the backup database is being restored.")
|
||||
progress:@(count / (CGFloat)self.databaseItems.count)];
|
||||
|
||||
@autoreleasepool {
|
||||
NSData *_Nullable compressedData =
|
||||
[self.backupIO decryptFileAsData:item.downloadFilePath encryptionKey:item.encryptionKey];
|
||||
if (!compressedData) {
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
NSData *_Nullable uncompressedData =
|
||||
[self.backupIO decompressData:compressedData
|
||||
uncompressedDataLength:item.uncompressedDataLength.unsignedIntValue];
|
||||
if (!uncompressedData) {
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
NSError *error;
|
||||
SignalIOSProtoBackupSnapshot *_Nullable entities =
|
||||
[SignalIOSProtoBackupSnapshot parseData:uncompressedData error:&error];
|
||||
if (!entities || error) {
|
||||
OWSLogError(@"could not parse proto: %@.", error);
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
if (!entities || entities.entity.count < 1) {
|
||||
OWSLogError(@"missing entities.");
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
for (SignalIOSProtoBackupSnapshotBackupEntity *entity in entities.entity) {
|
||||
NSData *_Nullable entityData = entity.entityData;
|
||||
if (entityData.length < 1) {
|
||||
OWSLogError(@"missing entity data.");
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *_Nullable collection = entity.collection;
|
||||
if (collection.length < 1) {
|
||||
OWSLogError(@"missing collection.");
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *_Nullable key = entity.key;
|
||||
if (key.length < 1) {
|
||||
OWSLogError(@"missing key.");
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
__block NSObject *object = nil;
|
||||
@try {
|
||||
NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:entityData];
|
||||
object = [unarchiver decodeObjectForKey:@"root"];
|
||||
if (![object isKindOfClass:[object class]]) {
|
||||
OWSLogError(@"invalid decoded entity: %@.", [object class]);
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
OWSLogError(@"could not decode entity.");
|
||||
// Database-related errors are unrecoverable.
|
||||
aborted = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
[transaction setObject:object forKey:key inCollection:collection];
|
||||
copiedEntities++;
|
||||
NSUInteger restoredEntityCount = restoredEntityCounts[collection].unsignedIntValue;
|
||||
restoredEntityCounts[collection] = @(restoredEntityCount + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
if (aborted) {
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import failed.")];
|
||||
}
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
for (NSString *collection in restoredEntityCounts) {
|
||||
OWSLogInfo(@"copied %@: %@", collection, restoredEntityCounts[collection]);
|
||||
}
|
||||
OWSLogInfo(@"copiedEntities: %llu", copiedEntities);
|
||||
|
||||
[self.primaryStorage logFileSizes];
|
||||
|
||||
return [AnyPromise promiseWithValue:@(1)];
|
||||
}
|
||||
|
||||
- (AnyPromise *)ensureMigrations
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")];
|
||||
}
|
||||
|
||||
[self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_FINALIZING",
|
||||
@"Indicates that the backup import data is being finalized.")
|
||||
progress:nil];
|
||||
|
||||
|
||||
// It's okay that we do this in a separate transaction from the
|
||||
// restoration of backup contents. If some of migrations don't
|
||||
// complete, they'll be run the next time the app launches.
|
||||
AnyPromise *promise = [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[[OWSDatabaseMigrationRunner alloc] init] runAllOutstandingWithCompletion:^{
|
||||
resolve(@(1));
|
||||
}];
|
||||
});
|
||||
}];
|
||||
return promise;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,92 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "TSYapDatabaseObject.h"
|
||||
#import <SessionMessagingKit/OWSBackupFragment.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const kOWSBackup_ManifestKey_DatabaseFiles;
|
||||
extern NSString *const kOWSBackup_ManifestKey_AttachmentFiles;
|
||||
extern NSString *const kOWSBackup_ManifestKey_RecordName;
|
||||
extern NSString *const kOWSBackup_ManifestKey_EncryptionKey;
|
||||
extern NSString *const kOWSBackup_ManifestKey_RelativeFilePath;
|
||||
extern NSString *const kOWSBackup_ManifestKey_AttachmentId;
|
||||
extern NSString *const kOWSBackup_ManifestKey_DataSize;
|
||||
extern NSString *const kOWSBackup_ManifestKey_LocalProfileAvatar;
|
||||
extern NSString *const kOWSBackup_ManifestKey_LocalProfileName;
|
||||
|
||||
@class AnyPromise;
|
||||
@class OWSBackupIO;
|
||||
@class OWSBackupJob;
|
||||
@class OWSBackupManifestContents;
|
||||
|
||||
typedef void (^OWSBackupJobBoolCompletion)(BOOL success);
|
||||
typedef void (^OWSBackupJobCompletion)(NSError *_Nullable error);
|
||||
typedef void (^OWSBackupJobManifestSuccess)(OWSBackupManifestContents *manifest);
|
||||
typedef void (^OWSBackupJobManifestFailure)(NSError *error);
|
||||
|
||||
@interface OWSBackupManifestContents : NSObject
|
||||
|
||||
@property (nonatomic) NSArray<OWSBackupFragment *> *databaseItems;
|
||||
@property (nonatomic) NSArray<OWSBackupFragment *> *attachmentsItems;
|
||||
@property (nonatomic, nullable) OWSBackupFragment *localProfileAvatarItem;
|
||||
@property (nonatomic, nullable) NSString *localProfileName;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@protocol OWSBackupJobDelegate <NSObject>
|
||||
|
||||
- (nullable NSData *)backupEncryptionKey;
|
||||
|
||||
// Either backupJobDidSucceed:... or backupJobDidFail:... will
|
||||
// be called exactly once on the main thread UNLESS:
|
||||
//
|
||||
// * The job was never started.
|
||||
// * The job was cancelled.
|
||||
- (void)backupJobDidSucceed:(OWSBackupJob *)backupJob;
|
||||
- (void)backupJobDidFail:(OWSBackupJob *)backupJob error:(NSError *)error;
|
||||
|
||||
- (void)backupJobDidUpdate:(OWSBackupJob *)backupJob
|
||||
description:(nullable NSString *)description
|
||||
progress:(nullable NSNumber *)progress;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface OWSBackupJob : NSObject
|
||||
|
||||
@property (nonatomic, weak, readonly) id<OWSBackupJobDelegate> delegate;
|
||||
|
||||
@property (nonatomic, readonly) NSString *recipientId;
|
||||
|
||||
// Indicates that the backup succeeded, failed or was cancelled.
|
||||
@property (atomic, readonly) BOOL isComplete;
|
||||
|
||||
@property (nonatomic, readonly) NSString *jobTempDirPath;
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
- (instancetype)initWithDelegate:(id<OWSBackupJobDelegate>)delegate recipientId:(NSString *)recipientId;
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (BOOL)ensureJobTempDir;
|
||||
|
||||
- (void)cancel;
|
||||
- (void)succeed;
|
||||
- (void)failWithErrorDescription:(NSString *)description;
|
||||
- (void)failWithError:(NSError *)error;
|
||||
- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress;
|
||||
|
||||
#pragma mark - Manifest
|
||||
|
||||
- (AnyPromise *)downloadAndProcessManifestWithBackupIO:(OWSBackupIO *)backupIO __attribute__((warn_unused_result));
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,316 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupJob.h"
|
||||
#import "OWSBackupIO.h"
|
||||
#import "Session-Swift.h"
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SignalCoreKit/Randomness.h>
|
||||
#import <YapDatabase/YapDatabaseCryptoUtils.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *const kOWSBackup_ManifestKey_DatabaseFiles = @"database_files";
|
||||
NSString *const kOWSBackup_ManifestKey_AttachmentFiles = @"attachment_files";
|
||||
NSString *const kOWSBackup_ManifestKey_RecordName = @"record_name";
|
||||
NSString *const kOWSBackup_ManifestKey_EncryptionKey = @"encryption_key";
|
||||
NSString *const kOWSBackup_ManifestKey_RelativeFilePath = @"relative_file_path";
|
||||
NSString *const kOWSBackup_ManifestKey_AttachmentId = @"attachment_id";
|
||||
NSString *const kOWSBackup_ManifestKey_DataSize = @"data_size";
|
||||
NSString *const kOWSBackup_ManifestKey_LocalProfileAvatar = @"local_profile_avatar";
|
||||
NSString *const kOWSBackup_ManifestKey_LocalProfileName = @"local_profile_name";
|
||||
|
||||
NSString *const kOWSBackup_KeychainService = @"kOWSBackup_KeychainService";
|
||||
|
||||
@implementation OWSBackupManifestContents
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface OWSBackupJob ()
|
||||
|
||||
@property (nonatomic, weak) id<OWSBackupJobDelegate> delegate;
|
||||
|
||||
@property (nonatomic) NSString *recipientId;
|
||||
|
||||
@property (atomic) BOOL isComplete;
|
||||
@property (atomic) BOOL hasSucceeded;
|
||||
|
||||
@property (nonatomic) NSString *jobTempDirPath;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupJob
|
||||
|
||||
- (instancetype)initWithDelegate:(id<OWSBackupJobDelegate>)delegate recipientId:(NSString *)recipientId
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
OWSAssertDebug([OWSStorage isStorageReady]);
|
||||
|
||||
self.delegate = delegate;
|
||||
self.recipientId = recipientId;
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
// Surface memory leaks by logging the deallocation.
|
||||
OWSLogVerbose(@"Dealloc: %@", self.class);
|
||||
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
|
||||
if (self.jobTempDirPath) {
|
||||
[OWSFileSystem deleteFileIfExists:self.jobTempDirPath];
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)ensureJobTempDir
|
||||
{
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
// TODO: Exports should use a new directory each time, but imports
|
||||
// might want to use a predictable directory so that repeated
|
||||
// import attempts can reuse downloads from previous attempts.
|
||||
NSString *temporaryDirectory = OWSTemporaryDirectory();
|
||||
self.jobTempDirPath = [temporaryDirectory stringByAppendingPathComponent:[NSUUID UUID].UUIDString];
|
||||
|
||||
if (![OWSFileSystem ensureDirectoryExists:self.jobTempDirPath]) {
|
||||
OWSFailDebug(@"Could not create jobTempDirPath.");
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (void)cancel
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.isComplete = YES;
|
||||
}
|
||||
|
||||
- (void)succeed
|
||||
{
|
||||
OWSLogInfo(@"");
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.isComplete) {
|
||||
OWSAssertDebug(!self.hasSucceeded);
|
||||
return;
|
||||
}
|
||||
self.isComplete = YES;
|
||||
|
||||
// There's a lot of asynchrony in these backup jobs;
|
||||
// ensure we only end up finishing these jobs once.
|
||||
OWSAssertDebug(!self.hasSucceeded);
|
||||
self.hasSucceeded = YES;
|
||||
|
||||
[self.delegate backupJobDidSucceed:self];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)failWithErrorDescription:(NSString *)description
|
||||
{
|
||||
[self failWithError:OWSErrorWithCodeDescription(OWSErrorCodeImportBackupFailed, description)];
|
||||
}
|
||||
|
||||
- (void)failWithError:(NSError *)error
|
||||
{
|
||||
OWSFailDebug(@"%@", error);
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
OWSAssertDebug(!self.hasSucceeded);
|
||||
if (self.isComplete) {
|
||||
return;
|
||||
}
|
||||
self.isComplete = YES;
|
||||
[self.delegate backupJobDidFail:self error:error];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress
|
||||
{
|
||||
OWSLogInfo(@"");
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.isComplete) {
|
||||
return;
|
||||
}
|
||||
[self.delegate backupJobDidUpdate:self description:description progress:progress];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Manifest
|
||||
|
||||
- (AnyPromise *)downloadAndProcessManifestWithBackupIO:(OWSBackupIO *)backupIO
|
||||
{
|
||||
OWSAssertDebug(backupIO);
|
||||
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup job no longer active.")];
|
||||
}
|
||||
|
||||
return
|
||||
[OWSBackupAPI downloadManifestFromCloudObjcWithRecipientId:self.recipientId].thenInBackground(^(NSData *data) {
|
||||
return [self processManifest:data backupIO:backupIO];
|
||||
});
|
||||
}
|
||||
|
||||
- (AnyPromise *)processManifest:(NSData *)manifestDataEncrypted backupIO:(OWSBackupIO *)backupIO
|
||||
{
|
||||
OWSAssertDebug(manifestDataEncrypted.length > 0);
|
||||
OWSAssertDebug(backupIO);
|
||||
|
||||
if (self.isComplete) {
|
||||
// Job was aborted.
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup job no longer active.")];
|
||||
}
|
||||
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
NSData *_Nullable manifestDataDecrypted =
|
||||
[backupIO decryptDataAsData:manifestDataEncrypted encryptionKey:self.delegate.backupEncryptionKey];
|
||||
if (!manifestDataDecrypted) {
|
||||
OWSFailDebug(@"Could not decrypt manifest.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not decrypt manifest.")];
|
||||
}
|
||||
|
||||
NSError *error;
|
||||
NSDictionary<NSString *, id> *_Nullable json =
|
||||
[NSJSONSerialization JSONObjectWithData:manifestDataDecrypted options:0 error:&error];
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
OWSFailDebug(@"Could not download manifest.");
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not download manifest.")];
|
||||
}
|
||||
|
||||
OWSLogVerbose(@"json: %@", json);
|
||||
|
||||
NSArray<OWSBackupFragment *> *_Nullable databaseItems =
|
||||
[self parseManifestItems:json key:kOWSBackup_ManifestKey_DatabaseFiles];
|
||||
if (!databaseItems) {
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"No database items in manifest.")];
|
||||
}
|
||||
NSArray<OWSBackupFragment *> *_Nullable attachmentsItems =
|
||||
[self parseManifestItems:json key:kOWSBackup_ManifestKey_AttachmentFiles];
|
||||
if (!attachmentsItems) {
|
||||
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"No attachment items in manifest.")];
|
||||
}
|
||||
|
||||
NSArray<OWSBackupFragment *> *_Nullable localProfileAvatarItems;
|
||||
if ([self parseManifestItem:json key:kOWSBackup_ManifestKey_LocalProfileAvatar]) {
|
||||
localProfileAvatarItems = [self parseManifestItems:json key:kOWSBackup_ManifestKey_LocalProfileAvatar];
|
||||
}
|
||||
|
||||
NSString *_Nullable localProfileName = [self parseManifestItem:json key:kOWSBackup_ManifestKey_LocalProfileName];
|
||||
|
||||
OWSBackupManifestContents *contents = [OWSBackupManifestContents new];
|
||||
contents.databaseItems = databaseItems;
|
||||
contents.attachmentsItems = attachmentsItems;
|
||||
contents.localProfileAvatarItem = localProfileAvatarItems.firstObject;
|
||||
if ([localProfileName isKindOfClass:[NSString class]]) {
|
||||
contents.localProfileName = localProfileName;
|
||||
} else if (localProfileName) {
|
||||
OWSFailDebug(@"Invalid localProfileName: %@", [localProfileName class]);
|
||||
}
|
||||
|
||||
return [AnyPromise promiseWithValue:contents];
|
||||
}
|
||||
|
||||
- (nullable id)parseManifestItem:(id)json key:(NSString *)key
|
||||
{
|
||||
OWSAssertDebug(json);
|
||||
OWSAssertDebug(key.length);
|
||||
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
OWSFailDebug(@"manifest has invalid data.");
|
||||
return nil;
|
||||
}
|
||||
id _Nullable value = json[key];
|
||||
return value;
|
||||
}
|
||||
|
||||
- (nullable NSArray<OWSBackupFragment *> *)parseManifestItems:(id)json key:(NSString *)key
|
||||
{
|
||||
OWSAssertDebug(json);
|
||||
OWSAssertDebug(key.length);
|
||||
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
OWSFailDebug(@"manifest has invalid data.");
|
||||
return nil;
|
||||
}
|
||||
NSArray *itemMaps = json[key];
|
||||
if (![itemMaps isKindOfClass:[NSArray class]]) {
|
||||
OWSFailDebug(@"manifest has invalid data.");
|
||||
return nil;
|
||||
}
|
||||
NSMutableArray<OWSBackupFragment *> *items = [NSMutableArray new];
|
||||
for (NSDictionary *itemMap in itemMaps) {
|
||||
if (![itemMap isKindOfClass:[NSDictionary class]]) {
|
||||
OWSFailDebug(@"manifest has invalid item.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable recordName = itemMap[kOWSBackup_ManifestKey_RecordName];
|
||||
NSString *_Nullable encryptionKeyString = itemMap[kOWSBackup_ManifestKey_EncryptionKey];
|
||||
NSString *_Nullable relativeFilePath = itemMap[kOWSBackup_ManifestKey_RelativeFilePath];
|
||||
NSString *_Nullable attachmentId = itemMap[kOWSBackup_ManifestKey_AttachmentId];
|
||||
NSNumber *_Nullable uncompressedDataLength = itemMap[kOWSBackup_ManifestKey_DataSize];
|
||||
if (![recordName isKindOfClass:[NSString class]]) {
|
||||
OWSFailDebug(@"manifest has invalid recordName: %@.", recordName);
|
||||
return nil;
|
||||
}
|
||||
if (![encryptionKeyString isKindOfClass:[NSString class]]) {
|
||||
OWSFailDebug(@"manifest has invalid encryptionKey.");
|
||||
return nil;
|
||||
}
|
||||
// relativeFilePath is an optional field.
|
||||
if (relativeFilePath && ![relativeFilePath isKindOfClass:[NSString class]]) {
|
||||
OWSLogDebug(@"manifest has invalid relativeFilePath: %@.", relativeFilePath);
|
||||
OWSFailDebug(@"manifest has invalid relativeFilePath");
|
||||
return nil;
|
||||
}
|
||||
// attachmentId is an optional field.
|
||||
if (attachmentId && ![attachmentId isKindOfClass:[NSString class]]) {
|
||||
OWSLogDebug(@"manifest has invalid attachmentId: %@.", attachmentId);
|
||||
OWSFailDebug(@"manifest has invalid attachmentId");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable encryptionKey = [NSData dataFromBase64String:encryptionKeyString];
|
||||
if (!encryptionKey) {
|
||||
OWSFailDebug(@"manifest has corrupt encryptionKey");
|
||||
return nil;
|
||||
}
|
||||
// uncompressedDataLength is an optional field.
|
||||
if (uncompressedDataLength && ![uncompressedDataLength isKindOfClass:[NSNumber class]]) {
|
||||
OWSFailDebug(@"manifest has invalid uncompressedDataLength: %@.", uncompressedDataLength);
|
||||
return nil;
|
||||
}
|
||||
|
||||
OWSBackupFragment *item = [[OWSBackupFragment alloc] initWithUniqueId:recordName];
|
||||
item.recordName = recordName;
|
||||
item.encryptionKey = encryptionKey;
|
||||
item.relativeFilePath = relativeFilePath;
|
||||
item.attachmentId = attachmentId;
|
||||
item.uncompressedDataLength = uncompressedDataLength;
|
||||
[items addObject:item];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,176 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc(OWSBackupLazyRestore)
|
||||
public class BackupLazyRestore: NSObject {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var backup: OWSBackup {
|
||||
return AppEnvironment.shared.backup
|
||||
}
|
||||
|
||||
private var primaryStorage: OWSPrimaryStorage {
|
||||
return SSKEnvironment.shared.primaryStorage
|
||||
}
|
||||
|
||||
private var tsAccountManager: TSAccountManager {
|
||||
return TSAccountManager.sharedInstance()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private var isRunning = false
|
||||
private var isComplete = false
|
||||
|
||||
@objc
|
||||
public required override init() {
|
||||
super.init()
|
||||
|
||||
SwiftSingletons.register(self)
|
||||
|
||||
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
||||
self.runIfNecessary()
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .OWSApplicationDidBecomeActive, object: nil, queue: nil) { _ in
|
||||
self.runIfNecessary()
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: .RegistrationStateDidChange, object: nil, queue: nil) { _ in
|
||||
self.runIfNecessary()
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: .reachabilityChanged, object: nil, queue: nil) { _ in
|
||||
self.runIfNecessary()
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: .reachabilityChanged, object: nil, queue: nil) { _ in
|
||||
self.runIfNecessary()
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: NSNotification.Name(NSNotificationNameBackupStateDidChange), object: nil, queue: nil) { _ in
|
||||
self.runIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private let backgroundQueue = DispatchQueue.global(qos: .background)
|
||||
|
||||
@objc
|
||||
public func clearCompleteAndRunIfNecessary() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
isComplete = false
|
||||
|
||||
runIfNecessary()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func isBackupImportInProgress() -> Bool {
|
||||
return backup.backupImportState == .inProgress
|
||||
}
|
||||
|
||||
@objc
|
||||
public func runIfNecessary() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard !CurrentAppContext().isRunningTests else {
|
||||
return
|
||||
}
|
||||
guard AppReadiness.isAppReady() else {
|
||||
return
|
||||
}
|
||||
guard CurrentAppContext().isMainAppAndActive else {
|
||||
return
|
||||
}
|
||||
guard tsAccountManager.isRegisteredAndReady() else {
|
||||
return
|
||||
}
|
||||
guard !isBackupImportInProgress() else {
|
||||
return
|
||||
}
|
||||
guard !isRunning, !isComplete else {
|
||||
return
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
|
||||
backgroundQueue.async {
|
||||
self.restoreAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreAttachments() {
|
||||
let temporaryDirectory = OWSTemporaryDirectory()
|
||||
let jobTempDirPath = (temporaryDirectory as NSString).appendingPathComponent(NSUUID().uuidString)
|
||||
|
||||
guard OWSFileSystem.ensureDirectoryExists(jobTempDirPath) else {
|
||||
Logger.error("could not create temp directory.")
|
||||
complete(errorCount: 1)
|
||||
return
|
||||
}
|
||||
|
||||
let backupIO = OWSBackupIO(jobTempDirPath: jobTempDirPath)
|
||||
|
||||
let attachmentIds = backup.attachmentIdsForLazyRestore()
|
||||
guard attachmentIds.count > 0 else {
|
||||
Logger.info("No attachments need lazy restore.")
|
||||
complete(errorCount: 0)
|
||||
return
|
||||
}
|
||||
Logger.info("Lazy restoring \(attachmentIds.count) attachments.")
|
||||
tryToRestoreNextAttachment(attachmentIds: attachmentIds, errorCount: 0, backupIO: backupIO)
|
||||
}
|
||||
|
||||
private func tryToRestoreNextAttachment(attachmentIds: [String], errorCount: UInt, backupIO: OWSBackupIO) {
|
||||
guard !isBackupImportInProgress() else {
|
||||
Logger.verbose("A backup import is in progress; abort.")
|
||||
complete(errorCount: errorCount + 1)
|
||||
return
|
||||
}
|
||||
|
||||
var attachmentIdsCopy = attachmentIds
|
||||
guard let attachmentId = attachmentIdsCopy.popLast() else {
|
||||
// This job is done.
|
||||
Logger.verbose("job is done.")
|
||||
complete(errorCount: errorCount)
|
||||
return
|
||||
}
|
||||
guard let attachmentPointer = TSAttachment.fetch(uniqueId: attachmentId) as? TSAttachmentPointer else {
|
||||
Logger.warn("could not load attachment.")
|
||||
// Not necessarily an error.
|
||||
// The attachment might have been deleted since the job began.
|
||||
// Continue trying to restore the other attachments.
|
||||
tryToRestoreNextAttachment(attachmentIds: attachmentIds, errorCount: errorCount + 1, backupIO: backupIO)
|
||||
return
|
||||
}
|
||||
backup.lazyRestoreAttachment(attachmentPointer,
|
||||
backupIO: backupIO)
|
||||
.done(on: self.backgroundQueue) { _ in
|
||||
Logger.info("Restored attachment.")
|
||||
|
||||
// Continue trying to restore the other attachments.
|
||||
self.tryToRestoreNextAttachment(attachmentIds: attachmentIdsCopy, errorCount: errorCount, backupIO: backupIO)
|
||||
}.catch(on: self.backgroundQueue) { (error) in
|
||||
Logger.error("Could not restore attachment: \(error)")
|
||||
|
||||
// Continue trying to restore the other attachments.
|
||||
self.tryToRestoreNextAttachment(attachmentIds: attachmentIdsCopy, errorCount: errorCount + 1, backupIO: backupIO)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
private func complete(errorCount: UInt) {
|
||||
Logger.verbose("")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isRunning = false
|
||||
|
||||
if errorCount == 0 {
|
||||
self.isComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <SignalUtilitiesKit/OWSTableViewController.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupSettingsViewController : OWSTableViewController
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -7,7 +7,6 @@
|
|||
#import "Session-Swift.h"
|
||||
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SignalUtilitiesKit/AttachmentSharing.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIColor+OWS.h>
|
||||
|
|
|
@ -0,0 +1,357 @@
|
|||
import Foundation
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
import PromiseKit
|
||||
import CallKit
|
||||
|
||||
public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||
|
||||
@objc static let isEnabled = true
|
||||
|
||||
// MARK: Metadata Properties
|
||||
let uuid: String
|
||||
let callID: UUID // This is for CallKit
|
||||
let sessionID: String
|
||||
let mode: Mode
|
||||
var audioMode: AudioMode
|
||||
let webRTCSession: WebRTCSession
|
||||
let isOutgoing: Bool
|
||||
var remoteSDP: RTCSessionDescription? = nil
|
||||
var callMessageID: String?
|
||||
var answerCallAction: CXAnswerCallAction? = nil
|
||||
var contactName: String {
|
||||
let contact = Storage.shared.getContact(with: self.sessionID)
|
||||
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
|
||||
}
|
||||
var profilePicture: UIImage {
|
||||
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
|
||||
return result
|
||||
} else {
|
||||
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Control
|
||||
lazy public var videoCapturer: RTCVideoCapturer = {
|
||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||
}()
|
||||
|
||||
var isRemoteVideoEnabled = false {
|
||||
didSet {
|
||||
remoteVideoStateDidChange?(isRemoteVideoEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
var isMuted = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.mute()
|
||||
} else {
|
||||
webRTCSession.unmute()
|
||||
}
|
||||
}
|
||||
}
|
||||
var isVideoEnabled = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case offer
|
||||
case answer
|
||||
}
|
||||
|
||||
// MARK: End call mode
|
||||
enum EndCallMode {
|
||||
case local
|
||||
case remote
|
||||
case unanswered
|
||||
case answeredElsewhere
|
||||
}
|
||||
|
||||
// MARK: Audio I/O mode
|
||||
enum AudioMode {
|
||||
case earpiece
|
||||
case speaker
|
||||
case headphone
|
||||
case bluetooth
|
||||
}
|
||||
|
||||
// MARK: Call State Properties
|
||||
var connectingDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasStartedConnectingDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var connectedDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasConnectedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var endDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasEndedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet implemented
|
||||
var isOnHold = false {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State Change Callbacks
|
||||
var stateDidChange: (() -> Void)?
|
||||
var hasStartedConnectingDidChange: (() -> Void)?
|
||||
var hasConnectedDidChange: (() -> Void)?
|
||||
var hasEndedDidChange: (() -> Void)?
|
||||
var remoteVideoStateDidChange: ((Bool) -> Void)?
|
||||
var hasStartedReconnecting: (() -> Void)?
|
||||
var hasReconnected: (() -> Void)?
|
||||
|
||||
// MARK: Derived Properties
|
||||
var hasStartedConnecting: Bool {
|
||||
get { return connectingDate != nil }
|
||||
set { connectingDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasConnected: Bool {
|
||||
get { return connectedDate != nil }
|
||||
set { connectedDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasEnded: Bool {
|
||||
get { return endDate != nil }
|
||||
set { endDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var timeOutTimer: Timer? = nil
|
||||
var didTimeout = false
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let connectedDate = connectedDate else {
|
||||
return 0
|
||||
}
|
||||
if let endDate = endDate {
|
||||
return endDate.timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
return Date().timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
var reconnectTimer: Timer? = nil
|
||||
|
||||
// MARK: Initialization
|
||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
||||
self.sessionID = sessionID
|
||||
self.uuid = uuid
|
||||
self.callID = UUID()
|
||||
self.mode = mode
|
||||
self.audioMode = .earpiece
|
||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
|
||||
self.isOutgoing = outgoing
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
super.init()
|
||||
self.webRTCSession.delegate = self
|
||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||
AppEnvironment.shared.callManager.currentCall = self
|
||||
} else {
|
||||
SNLog("[Calls] A call is ongoing.")
|
||||
}
|
||||
}
|
||||
|
||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||
guard case .answer = mode else { return }
|
||||
setupTimeoutTimer()
|
||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||
SNLog("[Calls] Did receive remote sdp.")
|
||||
remoteSDP = sdp
|
||||
if hasStartedConnecting {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
func startSessionCall() {
|
||||
guard case .offer = mode else { return }
|
||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
||||
|
||||
let message = CallMessage()
|
||||
message.sender = getUserHexEncodedPublicKey()
|
||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
||||
message.uuid = self.uuid
|
||||
message.kind = .preOffer
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save()
|
||||
self.callMessageID = infoMessage.uniqueId
|
||||
|
||||
var promise: Promise<Void>!
|
||||
Storage.write(with: { transaction in
|
||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
let _ = promise.done {
|
||||
Storage.shared.write { transaction in
|
||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
||||
}
|
||||
self?.setupTimeoutTimer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
guard case .answer = mode else { return }
|
||||
hasStartedConnecting = true
|
||||
if let sdp = remoteSDP {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
func answerSessionCallInBackground(action: CXAnswerCallAction) {
|
||||
answerCallAction = action
|
||||
self.answerSessionCall()
|
||||
}
|
||||
|
||||
func endSessionCall() {
|
||||
guard !hasEnded else { return }
|
||||
webRTCSession.hangUp()
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
|
||||
}
|
||||
hasEnded = true
|
||||
}
|
||||
|
||||
// MARK: Update call message
|
||||
func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callMessageID = callMessageID else { return }
|
||||
Storage.write { transaction in
|
||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
||||
if let messageToUpdate = infoMessage {
|
||||
var shouldMarkAsRead = false
|
||||
if self.duration > 0 {
|
||||
shouldMarkAsRead = true
|
||||
} else if self.hasStartedConnecting {
|
||||
shouldMarkAsRead = true
|
||||
} else {
|
||||
switch mode {
|
||||
case .local:
|
||||
shouldMarkAsRead = true
|
||||
fallthrough
|
||||
case .remote:
|
||||
fallthrough
|
||||
case .unanswered:
|
||||
if messageToUpdate.callState == .incoming {
|
||||
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
case .answeredElsewhere:
|
||||
shouldMarkAsRead = true
|
||||
}
|
||||
}
|
||||
if shouldMarkAsRead {
|
||||
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Renderer
|
||||
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.removeRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachLocalRenderer(renderer)
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
public func webRTCIsConnected() {
|
||||
self.invalidateTimeoutTimer()
|
||||
self.reconnectTimer?.invalidate()
|
||||
guard !self.hasConnected else {
|
||||
hasReconnected?()
|
||||
return
|
||||
}
|
||||
self.hasConnected = true
|
||||
self.answerCallAction?.fulfill()
|
||||
}
|
||||
|
||||
public func isRemoteVideoDidChange(isEnabled: Bool) {
|
||||
isRemoteVideoEnabled = isEnabled
|
||||
}
|
||||
|
||||
public func didReceiveHangUpSignal() {
|
||||
self.hasEnded = true
|
||||
DispatchQueue.main.async {
|
||||
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
|
||||
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
public func dataChannelDidOpen() {
|
||||
// Send initial video status
|
||||
if (isVideoEnabled) {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnectIfNeeded() {
|
||||
setupTimeoutTimer()
|
||||
hasStartedReconnecting?()
|
||||
guard isOutgoing else { return }
|
||||
tryToReconnect()
|
||||
}
|
||||
|
||||
private func tryToReconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
||||
}
|
||||
} else {
|
||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||
self.tryToReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Timeout
|
||||
public func setupTimeoutTimer() {
|
||||
invalidateTimeoutTimer()
|
||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||
self.didTimeout = true
|
||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||
self.timeOutTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateTimeoutTimer() {
|
||||
timeOutTimer?.invalidate()
|
||||
timeOutTimer = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
public func startCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.startSessionCall()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func answerCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
||||
call.answerSessionCall()
|
||||
} else {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.currentCall!)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
call.answerSessionCall()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func endCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.endSessionCall()
|
||||
if call.didTimeout {
|
||||
reportCurrentCallEnded(reason: .unanswered)
|
||||
} else {
|
||||
reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.isMuted = isMuted
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import CallKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager {
|
||||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
guard case .offer = call.mode else { return }
|
||||
guard !call.hasConnected else { return }
|
||||
reportOutgoingCall(call)
|
||||
if callController != nil {
|
||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
||||
|
||||
startCallAction.isVideo = false
|
||||
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(startCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
startCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(answerCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
answerCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let endCallAction = CXEndCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(endCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
endCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Not currently in use
|
||||
public func setOnHoldStatus(for call: SessionCall) {
|
||||
if callController != nil {
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(setHeldCallAction)
|
||||
|
||||
requestTransaction(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestTransaction(_ transaction: CXTransaction, completion: ((Error?) -> Void)? = nil) {
|
||||
callController?.request(transaction) { error in
|
||||
if let error = error {
|
||||
SNLog("Error requesting transaction: \(error)")
|
||||
} else {
|
||||
SNLog("Requested transaction successfully")
|
||||
}
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import CallKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
AssertIsOnMainThread()
|
||||
currentCall?.endSessionCall()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
if startCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
print("[CallKit] Perform CXAnswerCallAction")
|
||||
guard let call = self.currentCall else { return action.fail() }
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
if answerCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
} else {
|
||||
call.answerSessionCallInBackground(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
print("[CallKit] Perform CXEndCallAction")
|
||||
AssertIsOnMainThread()
|
||||
if endCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||
AssertIsOnMainThread()
|
||||
if setMutedCallAction(isMuted: action.isMuted) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
|
||||
// TODO: set on hold
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
||||
// TODO: handle timeout
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did activate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did deactivate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import CallKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCallManager: NSObject {
|
||||
let provider: CXProvider?
|
||||
let callController: CXCallController?
|
||||
var currentCall: SessionCall? = nil {
|
||||
willSet {
|
||||
if (newValue != nil) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var _sharedProvider: CXProvider?
|
||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||
|
||||
if let sharedProvider = self._sharedProvider {
|
||||
sharedProvider.configuration = configuration
|
||||
return sharedProvider
|
||||
} else {
|
||||
SwiftSingletons.register(self)
|
||||
let provider = CXProvider(configuration: configuration)
|
||||
_sharedProvider = provider
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
||||
providerConfiguration.supportsVideo = true
|
||||
providerConfiguration.maximumCallGroups = 1
|
||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||
providerConfiguration.supportedHandleTypes = [.generic]
|
||||
let iconMaskImage = #imageLiteral(resourceName: "SessionGreen32")
|
||||
providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()
|
||||
providerConfiguration.includesCallsInRecents = useSystemCallLog
|
||||
|
||||
return providerConfiguration
|
||||
}
|
||||
|
||||
init(useSystemCallLog: Bool = false) {
|
||||
AssertIsOnMainThread()
|
||||
if SSKPreferences.isCallKitSupported {
|
||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
self.callController = CXCallController()
|
||||
} else {
|
||||
self.provider = nil
|
||||
self.callController = nil
|
||||
}
|
||||
super.init()
|
||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||
self.provider?.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: Report calls
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
||||
}
|
||||
if call.hasConnected {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reportIncomingCall(_ call: SessionCall, callerName: String, completion: @escaping (Error?) -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if let provider = provider {
|
||||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = callerName
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
||||
update.hasVideo = false
|
||||
|
||||
disableUnsupportedFeatures(callUpdate: update)
|
||||
|
||||
// Report the incoming call to the system
|
||||
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
|
||||
guard error == nil else {
|
||||
self.reportCurrentCallEnded(reason: .failed)
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
} else {
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||
guard let call = currentCall else { return }
|
||||
if let reason = reason {
|
||||
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
|
||||
switch (reason) {
|
||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||
default: call.updateCallMessage(mode: .remote)
|
||||
}
|
||||
} else {
|
||||
call.updateCallMessage(mode: .local)
|
||||
}
|
||||
call.webRTCSession.dropConnection()
|
||||
self.currentCall = nil
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
|
||||
}
|
||||
|
||||
// MARK: Util
|
||||
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||
// until user returns to in-app call screen.
|
||||
callUpdate.supportsHolding = false
|
||||
|
||||
// Not yet supported
|
||||
callUpdate.supportsGrouping = false
|
||||
callUpdate.supportsUngrouping = false
|
||||
|
||||
// Is there any reason to support this?
|
||||
callUpdate.supportsDTMF = false
|
||||
}
|
||||
|
||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
||||
let message = CallMessage()
|
||||
message.uuid = offerMessage.uuid
|
||||
message.kind = .endCall
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import WebRTC
|
||||
|
||||
extension CallVC : CameraManagerDelegate {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
|
||||
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
||||
let timestampNs = Int64(timestamp * 1000000000)
|
||||
let rotation: RTCVideoRotation = {
|
||||
switch UIDevice.current.orientation {
|
||||
case .landscapeRight: return RTCVideoRotation._90
|
||||
case .portraitUpsideDown: return RTCVideoRotation._180
|
||||
case .landscapeLeft: return RTCVideoRotation._270
|
||||
default: return RTCVideoRotation._0
|
||||
}
|
||||
}()
|
||||
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs)
|
||||
frame.timeStamp = Int32(timestamp)
|
||||
call.webRTCSession.handleLocalFrameCaptured(frame)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,550 @@
|
|||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
var durationTimer: Timer?
|
||||
var duration: Int = 0
|
||||
var shouldRestartCamera = true
|
||||
weak var conversationVC: ConversationVC? = nil
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var localVideoView: LocalVideoView = {
|
||||
let result = LocalVideoView()
|
||||
result.isHidden = !call.isVideoEnabled
|
||||
result.layer.cornerRadius = 10
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.width, to: LocalVideoView.width)
|
||||
result.set(.height, to: LocalVideoView.height)
|
||||
result.makeViewDraggable()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var remoteVideoView: RemoteVideoView = {
|
||||
let result = RemoteVideoView()
|
||||
result.alpha = 0
|
||||
result.backgroundColor = .black
|
||||
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped)))
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profilePictureView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
|
||||
result.image = self.call.profilePicture
|
||||
result.set(.width, to: radius * 2)
|
||||
result.set(.height, to: radius * 2)
|
||||
result.layer.cornerRadius = radius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var minimizeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = !call.hasConnected
|
||||
let image = UIImage(named: "Minimize")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = call.hasStartedConnecting
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var responsePanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing * 2 + 40
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchCameraButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isEnabled = call.isVideoEnabled
|
||||
let image = UIImage(named: "SwitchCamera")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchAudioButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AudioOff")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = call.isMuted ? Colors.destructive : UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var videoButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "VideoCall")?.withRenderingMode(.alwaysTemplate)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var volumeView: MPVolumeView = {
|
||||
let result = MPVolumeView()
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
result.showsVolumeSlider = false
|
||||
result.showsRouteButton = true
|
||||
result.setRouteButtonImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var operationPanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callInfoLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = call.hasConnected
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
if call.hasStartedConnecting { result.text = "Connecting..." }
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callDurationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = true
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
setupStateChangeCallbacks()
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
func setupStateChangeCallbacks() {
|
||||
self.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
}
|
||||
if self.callInfoLabel.alpha < 0.5 {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.call.hasStartedConnectingDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.text = "Connecting..."
|
||||
self.answerButton.alpha = 0
|
||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseIn, animations: {
|
||||
self.answerButton.isHidden = true
|
||||
}, completion: nil)
|
||||
}
|
||||
}
|
||||
self.call.hasConnectedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
self.callInfoLabel.text = "Connected"
|
||||
self.minimizeButton.isHidden = false
|
||||
self.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.updateDuration()
|
||||
}
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
self.call.hasEndedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.durationTimer?.invalidate()
|
||||
self.durationTimer = nil
|
||||
self.handleEndCallMessage()
|
||||
}
|
||||
}
|
||||
self.call.hasStartedReconnecting = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
self.callInfoLabel.text = "Reconnecting..."
|
||||
}
|
||||
}
|
||||
self.call.hasReconnected = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
if shouldRestartCamera { cameraManager.prepare() }
|
||||
touch(call.videoCapturer)
|
||||
titleLabel.text = self.call.contactName
|
||||
AppEnvironment.shared.callManager.startCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't start a call."
|
||||
self.endCall()
|
||||
} else {
|
||||
self.callInfoLabel.text = "Ringing..."
|
||||
self.answerButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
setupOrientationMonitoring()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Profile picture container
|
||||
let profilePictureContainer = UIView()
|
||||
view.addSubview(profilePictureContainer)
|
||||
// Remote video view
|
||||
call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
view.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: view)
|
||||
// Local video view
|
||||
call.attachLocalVideoRenderer(localVideoView)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Minimize button
|
||||
view.addSubview(minimizeButton)
|
||||
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
minimizeButton.pin(.left, to: .left, of: view)
|
||||
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: minimizeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
// Response Panel
|
||||
view.addSubview(responsePanel)
|
||||
responsePanel.center(.horizontal, in: view)
|
||||
responsePanel.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset)
|
||||
// Operation Panel
|
||||
view.addSubview(operationPanel)
|
||||
operationPanel.center(.horizontal, in: view)
|
||||
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
|
||||
// Profile picture view
|
||||
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
|
||||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
profilePictureContainer.addSubview(profilePictureView)
|
||||
profilePictureView.center(in: profilePictureContainer)
|
||||
// Call info label
|
||||
let callInfoLabelContainer = UIView()
|
||||
view.addSubview(callInfoLabelContainer)
|
||||
callInfoLabelContainer.pin(.top, to: .bottom, of: profilePictureView)
|
||||
callInfoLabelContainer.pin(.bottom, to: .bottom, of: profilePictureContainer)
|
||||
callInfoLabelContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
callInfoLabelContainer.addSubview(callInfoLabel)
|
||||
callInfoLabelContainer.addSubview(callDurationLabel)
|
||||
callInfoLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callInfoLabel.center(in: callInfoLabelContainer)
|
||||
callDurationLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callDurationLabel.center(in: callInfoLabelContainer)
|
||||
}
|
||||
|
||||
private func addLocalVideoView() {
|
||||
let safeAreaInsets = UIApplication.shared.keyWindow!.safeAreaInsets
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(localVideoView)
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
|
||||
let topMargin = safeAreaInsets.top + Values.veryLargeSpacing
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
|
||||
shouldRestartCamera = true
|
||||
addLocalVideoView()
|
||||
remoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
|
||||
localVideoView.removeFromSuperview()
|
||||
}
|
||||
|
||||
// MARK: - Orientation
|
||||
|
||||
private func setupOrientationMonitoring() {
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
|
||||
}
|
||||
|
||||
@objc func didChangeDeviceOrientation(notification: Notification) {
|
||||
|
||||
func rotateAllButtons(rotationAngle: CGFloat) {
|
||||
let transform = CGAffineTransform(rotationAngle: rotationAngle)
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.answerButton.transform = transform
|
||||
self.hangUpButton.transform = transform
|
||||
self.switchAudioButton.transform = transform
|
||||
self.switchCameraButton.transform = transform
|
||||
self.videoButton.transform = transform
|
||||
self.volumeView.transform = transform
|
||||
}
|
||||
}
|
||||
|
||||
switch UIDevice.current.orientation {
|
||||
case .portrait:
|
||||
rotateAllButtons(rotationAngle: 0)
|
||||
case .portraitUpsideDown:
|
||||
rotateAllButtons(rotationAngle: .pi)
|
||||
case .landscapeLeft:
|
||||
rotateAllButtons(rotationAngle: .halfPi)
|
||||
case .landscapeRight:
|
||||
rotateAllButtons(rotationAngle: .pi + .halfPi)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Call signalling
|
||||
func handleAnswerMessage(_ message: CallMessage) {
|
||||
callInfoLabel.text = "Connecting..."
|
||||
}
|
||||
|
||||
func handleEndCallMessage() {
|
||||
SNLog("[Calls] Ending call.")
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
callInfoLabel.text = "Call Ended"
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = 0
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
AppEnvironment.shared.callManager.answerCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't answer the call."
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateDuration() {
|
||||
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
|
||||
duration += 1
|
||||
}
|
||||
|
||||
// MARK: Minimize to a floating view
|
||||
@objc private func minimize() {
|
||||
self.shouldRestartCamera = false
|
||||
let miniCallView = MiniCallView(from: self)
|
||||
miniCallView.show()
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Video and Audio
|
||||
@objc private func operateCamera() {
|
||||
if (call.isVideoEnabled) {
|
||||
localVideoView.isHidden = true
|
||||
cameraManager.stop()
|
||||
videoButton.tintColor = .white
|
||||
videoButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
switchCameraButton.isEnabled = false
|
||||
call.isVideoEnabled = false
|
||||
} else {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
let previewVC = VideoPreviewVC()
|
||||
previewVC.delegate = self
|
||||
present(previewVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDidConfirmTurningOn() {
|
||||
localVideoView.isHidden = false
|
||||
cameraManager.prepare()
|
||||
cameraManager.start()
|
||||
videoButton.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
videoButton.backgroundColor = .white
|
||||
switchCameraButton.isEnabled = true
|
||||
call.isVideoEnabled = true
|
||||
}
|
||||
|
||||
@objc private func switchCamera() {
|
||||
cameraManager.switchCamera()
|
||||
}
|
||||
|
||||
@objc private func switchAudio() {
|
||||
if call.isMuted {
|
||||
switchAudioButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
call.isMuted = false
|
||||
} else {
|
||||
switchAudioButton.backgroundColor = Colors.destructive
|
||||
call.isMuted = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func audioRouteDidChange() {
|
||||
let currentSession = AVAudioSession.sharedInstance()
|
||||
let currentRoute = currentSession.currentRoute
|
||||
if let currentOutput = currentRoute.outputs.first {
|
||||
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
|
||||
latestKnownAudioOutputDeviceName = currentOutput.portName
|
||||
switch currentOutput.portType {
|
||||
case .builtInSpeaker:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .headphones:
|
||||
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothLE: fallthrough
|
||||
case .bluetoothA2DP:
|
||||
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothHFP:
|
||||
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .builtInReceiver: fallthrough
|
||||
default:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = .white
|
||||
volumeView.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) {
|
||||
let isHidden = callDurationLabel.alpha < 0.5
|
||||
UIView.animate(withDuration: 0.5) {
|
||||
self.operationPanel.alpha = isHidden ? 1 : 0
|
||||
self.responsePanel.alpha = isHidden ? 1 : 0
|
||||
self.callDurationLabel.alpha = isHidden ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc
|
||||
protocol CameraManagerDelegate : AnyObject {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer)
|
||||
}
|
||||
|
||||
final class CameraManager : NSObject {
|
||||
private let captureSession = AVCaptureSession()
|
||||
private let videoDataOutput = AVCaptureVideoDataOutput()
|
||||
private let videoDataOutputQueue
|
||||
= DispatchQueue(label: "CameraManager.videoDataOutputQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
|
||||
private let audioDataOutput = AVCaptureAudioDataOutput()
|
||||
private var isCapturing = false
|
||||
weak var delegate: CameraManagerDelegate?
|
||||
|
||||
private var videoCaptureDevice: AVCaptureDevice?
|
||||
private var videoInput: AVCaptureDeviceInput?
|
||||
|
||||
func prepare() {
|
||||
print("[Calls] Preparing camera.")
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
|
||||
private func addNewVideoIO(position: AVCaptureDevice.Position) {
|
||||
if let videoCaptureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
|
||||
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), captureSession.canAddInput(videoInput) {
|
||||
captureSession.addInput(videoInput)
|
||||
self.videoCaptureDevice = videoCaptureDevice
|
||||
self.videoInput = videoInput
|
||||
}
|
||||
if captureSession.canAddOutput(videoDataOutput) {
|
||||
captureSession.addOutput(videoDataOutput)
|
||||
videoDataOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String : Int(kCVPixelFormatType_32BGRA) ]
|
||||
videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
|
||||
guard let connection = videoDataOutput.connection(with: AVMediaType.video) else { return }
|
||||
connection.videoOrientation = .portrait
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = (position == .front)
|
||||
} else {
|
||||
SNLog("Couldn't add video data output to capture session.")
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isCapturing else { return }
|
||||
print("[Calls] Starting camera.")
|
||||
isCapturing = true
|
||||
captureSession.startRunning()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isCapturing else { return }
|
||||
print("[Calls] Stopping camera.")
|
||||
isCapturing = false
|
||||
captureSession.stopRunning()
|
||||
}
|
||||
|
||||
func switchCamera() {
|
||||
guard let videoCaptureDevice = videoCaptureDevice, let videoInput = videoInput else { return }
|
||||
stop()
|
||||
if videoCaptureDevice.position == .front {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .back)
|
||||
} else {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
extension CameraManager : AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
guard connection == videoDataOutput.connection(with: .video) else { return }
|
||||
delegate?.handleVideoOutputCaptured(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
print("[Calls] Frame dropped.")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
|
||||
public protocol VideoPreviewDelegate : AnyObject {
|
||||
func cameraDidConfirmTurningOn()
|
||||
}
|
||||
|
||||
class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
||||
weak var delegate: VideoPreviewDelegate?
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var renderView: RenderView = {
|
||||
let result = RenderView()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var closeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "X")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "Check")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Preview"
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
cameraManager.prepare()
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Preview video view
|
||||
view.addSubview(renderView)
|
||||
renderView.translatesAutoresizingMaskIntoConstraints = false
|
||||
renderView.pin(to: view)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Close button
|
||||
view.addSubview(closeButton)
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButton.pin(.left, to: .left, of: view)
|
||||
closeButton.center(.vertical, in: fadeView)
|
||||
// Confirm button
|
||||
view.addSubview(confirmButton)
|
||||
confirmButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
confirmButton.pin(.right, to: .right, of: view)
|
||||
confirmButton.center(.vertical, in: fadeView)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: closeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
cameraManager.start()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cameraManager.stop()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func confirm() {
|
||||
delegate?.cameraDidConfirmTurningOn()
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func cancel() {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: CameraManagerDelegate
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
renderView.enqueue(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import UIKit
|
||||
|
||||
@objc
|
||||
final class CallMissedTipsModal : Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Tips icon
|
||||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
tipsIconImageView.set(.height, to: 28)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
// Cancel Button
|
||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
import WebRTC
|
||||
import Foundation
|
||||
|
||||
// MARK: RemoteVideoView
|
||||
|
||||
class RemoteVideoView: RTCMTLVideoView {
|
||||
|
||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
super.renderFrame(frame)
|
||||
guard let frame = frame else { return }
|
||||
DispatchMainThreadSafe {
|
||||
let frameRatio = Double(frame.height) / Double(frame.width)
|
||||
let frameRotation = frame.rotation
|
||||
let deviceRotation = UIDevice.current.orientation
|
||||
var rotationOverride: RTCVideoRotation? = nil
|
||||
switch deviceRotation {
|
||||
case .portrait, .portraitUpsideDown:
|
||||
// We don't have to do anything, the renderer will automatically make sure it's right-side-up.
|
||||
break
|
||||
case .landscapeLeft:
|
||||
switch frameRotation {
|
||||
case RTCVideoRotation._0: rotationOverride = RTCVideoRotation._90 // Landscape left
|
||||
case RTCVideoRotation._90: rotationOverride = RTCVideoRotation._180 // Portrait
|
||||
case RTCVideoRotation._180: rotationOverride = RTCVideoRotation._270 // Landscape right
|
||||
case RTCVideoRotation._270: rotationOverride = RTCVideoRotation._0 // Portrait upside-down
|
||||
default: break
|
||||
}
|
||||
case .landscapeRight:
|
||||
switch frameRotation {
|
||||
case RTCVideoRotation._0: rotationOverride = RTCVideoRotation._270 // Landscape left
|
||||
case RTCVideoRotation._90: rotationOverride = RTCVideoRotation._0 // Portrait
|
||||
case RTCVideoRotation._180: rotationOverride = RTCVideoRotation._90 // Landscape right
|
||||
case RTCVideoRotation._270: rotationOverride = RTCVideoRotation._180 // Portrait upside-down
|
||||
default: break
|
||||
}
|
||||
default:
|
||||
// Do nothing if we're face down, up, etc.
|
||||
// Assume we're already setup for the correct orientation.
|
||||
break
|
||||
}
|
||||
|
||||
if let rotationOverride = rotationOverride {
|
||||
self.rotationOverride = NSNumber(value: rotationOverride.rawValue)
|
||||
if [ RTCVideoRotation._0, RTCVideoRotation._180 ].contains(rotationOverride) {
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
} else {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
} else {
|
||||
self.rotationOverride = nil
|
||||
if [ RTCVideoRotation._0, RTCVideoRotation._180 ].contains(frameRotation) {
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
} else {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
}
|
||||
// if not a mobile ratio, always use .scaleAspectFit
|
||||
if frameRatio < 1.5 {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LocalVideoView
|
||||
|
||||
class LocalVideoView: RTCMTLVideoView {
|
||||
|
||||
static let width: CGFloat = 80
|
||||
static let height: CGFloat = 173
|
||||
|
||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
super.renderFrame(frame)
|
||||
DispatchMainThreadSafe {
|
||||
// This is a workaround for a weird issue that
|
||||
// sometimes the rotationOverride is not working
|
||||
// if it is only set once on initialization
|
||||
self.rotationOverride = NSNumber(value: RTCVideoRotation._0.rawValue)
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
private static let swipeToOperateThreshold: CGFloat = 60
|
||||
private var previousY: CGFloat = 0
|
||||
let call: SessionCall
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result = ProfilePictureView()
|
||||
let size = CGFloat(60)
|
||||
result.size = size
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = UIColor.white
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 24.8, height: 24.8))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 29.6, height: 11.2))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: IncomingCallBanner?
|
||||
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
if let incomingCallBanner = IncomingCallBanner.current {
|
||||
incomingCallBanner.dismiss()
|
||||
}
|
||||
IncomingCallBanner.current = self
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(0.8)
|
||||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
self.set(.height, to: 100)
|
||||
profilePictureView.publicKey = call.sessionID
|
||||
profilePictureView.update()
|
||||
displayNameLabel.text = call.contactName
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = Values.largeSpacing
|
||||
self.addSubview(stackView)
|
||||
stackView.center(.vertical, in: self)
|
||||
stackView.autoPinWidthToSuperview(withMargin: Values.mediumSpacing)
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == panGestureRecognizer {
|
||||
let v = panGestureRecognizer.velocity(in: self)
|
||||
return abs(v.y) > abs(v.x) // It has to be more vertical than horizontal
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
showCallVC(answer: false)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
let translationY = gestureRecognizer.translation(in: self).y
|
||||
switch gestureRecognizer.state {
|
||||
case .changed:
|
||||
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
|
||||
}
|
||||
previousY = translationY
|
||||
case .ended, .cancelled:
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
|
||||
if translationY > 0 { showCallVC(answer: false) }
|
||||
else { endCall() } // TODO: Or just put the call on hold?
|
||||
} else {
|
||||
self.transform = .identity
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
showCallVC(answer: true)
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
public func showCallVC(answer: Bool) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.call)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
if answer { self.call.answerSessionCall() }
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
let topMargin = window.safeAreaInsets.top - Values.smallSpacing
|
||||
self.autoPinWidthToSuperview(withMargin: Values.smallSpacing)
|
||||
self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
CallRingTonePlayer.shared.startVibration()
|
||||
CallRingTonePlayer.shared.startPlayingRingTone()
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
CallRingTonePlayer.shared.stopVibrationIfPossible()
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
IncomingCallBanner.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
|
||||
final class MiniCallView: UIView, RTCVideoViewDelegate {
|
||||
var callVC: CallVC
|
||||
|
||||
// MARK: UI
|
||||
private static let defaultSize: CGFloat = 100
|
||||
private let topMargin = UIApplication.shared.keyWindow!.safeAreaInsets.top + Values.veryLargeSpacing
|
||||
private let bottomMargin = UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
|
||||
private var width: NSLayoutConstraint?
|
||||
private var height: NSLayoutConstraint?
|
||||
private var left: NSLayoutConstraint?
|
||||
private var right: NSLayoutConstraint?
|
||||
private var top: NSLayoutConstraint?
|
||||
private var bottom: NSLayoutConstraint?
|
||||
|
||||
private lazy var remoteVideoView: RTCMTLVideoView = {
|
||||
let result = RTCMTLVideoView()
|
||||
result.delegate = self
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.videoContentMode = .scaleAspectFit
|
||||
result.backgroundColor = .black
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: MiniCallView?
|
||||
|
||||
init(from callVC: CallVC) {
|
||||
self.callVC = callVC
|
||||
super.init(frame: CGRect.zero)
|
||||
self.backgroundColor = UIColor.init(white: 0, alpha: 0.8)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
MiniCallView.current = self
|
||||
self.callVC.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
if !isEnabled {
|
||||
self.width?.constant = MiniCallView.defaultSize
|
||||
self.height?.constant = MiniCallView.defaultSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.width = self.set(.width, to: MiniCallView.defaultSize)
|
||||
self.height = self.set(.height, to: MiniCallView.defaultSize)
|
||||
self.layer.cornerRadius = 10
|
||||
self.layer.masksToBounds = true
|
||||
// Background
|
||||
let background = getBackgroudView()
|
||||
self.addSubview(background)
|
||||
background.pin(to: self)
|
||||
// Remote video view
|
||||
callVC.call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
self.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: self)
|
||||
}
|
||||
|
||||
private func getBackgroudView() -> UIView {
|
||||
let background = UIView()
|
||||
let imageView = UIImageView()
|
||||
imageView.layer.cornerRadius = 32
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.image = callVC.call.profilePicture
|
||||
background.addSubview(imageView)
|
||||
imageView.set(.width, to: 64)
|
||||
imageView.set(.height, to: 64)
|
||||
imageView.center(in: background)
|
||||
return background
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
makeViewDraggable()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
left = self.autoPinEdge(toSuperviewEdge: .left)
|
||||
left?.isActive = false
|
||||
right = self.autoPinEdge(toSuperviewEdge: .right)
|
||||
top = self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
bottom = self.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin)
|
||||
bottom?.isActive = false
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
self.callVC.call.removeRemoteVideoRenderer(self.remoteVideoView)
|
||||
self.callVC.setupStateChangeCallbacks()
|
||||
MiniCallView.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: RTCVideoViewDelegate
|
||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||
let newSize = CGSize(width: min(160.0, 160.0 * size.width / size.height), height: min(160.0, 160.0 * size.height / size.width))
|
||||
persistCurrentPosition(newSize: newSize)
|
||||
self.width?.constant = newSize.width
|
||||
self.height?.constant = newSize.height
|
||||
}
|
||||
|
||||
func persistCurrentPosition(newSize: CGSize) {
|
||||
let currentCenter = self.center
|
||||
|
||||
if currentCenter.x < self.superview!.width() / 2 {
|
||||
left?.isActive = true
|
||||
right?.isActive = false
|
||||
} else {
|
||||
left?.isActive = false
|
||||
right?.isActive = true
|
||||
}
|
||||
|
||||
let willTouchTop = currentCenter.y < newSize.height / 2 + topMargin
|
||||
let willTouchBottom = currentCenter.y + newSize.height / 2 >= self.superview!.height()
|
||||
if willTouchBottom {
|
||||
top?.isActive = false
|
||||
bottom?.isActive = true
|
||||
} else {
|
||||
let constant = willTouchTop ? topMargin : currentCenter.y - newSize.height / 2
|
||||
top?.constant = constant
|
||||
top?.isActive = true
|
||||
bottom?.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import CoreMedia
|
||||
|
||||
class RenderView: UIView {
|
||||
|
||||
private lazy var displayLayer: AVSampleBufferDisplayLayer = {
|
||||
let result = AVSampleBufferDisplayLayer()
|
||||
result.videoGravity = .resizeAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
self.layer.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
displayLayer.frame = self.bounds
|
||||
}
|
||||
|
||||
public func enqueue(sampleBuffer: CMSampleBuffer) {
|
||||
displayLayer.enqueue(sampleBuffer)
|
||||
}
|
||||
|
||||
}
|
|
@ -174,8 +174,7 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction)
|
||||
}
|
||||
let _ = promise.done(on: DispatchQueue.main) { thread in
|
||||
let appDelegate = UIApplication.shared.delegate as! AppDelegate
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside createClosedGroup(...)
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
||||
}
|
||||
|
|
|
@ -218,11 +218,11 @@ public class ConversationMessageMapping: NSObject {
|
|||
return IndexPath(row: oldIndex, section: 0)
|
||||
}
|
||||
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
|
||||
owsFailDebug("Could not load view.")
|
||||
SNLog("Could not load view.")
|
||||
return nil
|
||||
}
|
||||
guard let group = group else {
|
||||
owsFailDebug("No group.")
|
||||
SNLog("No group.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -235,7 +235,7 @@ public class ConversationMessageMapping: NSObject {
|
|||
let index = indexPtr.pointee
|
||||
let threadInteractionCount = view.numberOfItems(inGroup: group)
|
||||
guard index < threadInteractionCount else {
|
||||
owsFailDebug("Invalid index.")
|
||||
SNLog("Invalid index.")
|
||||
return nil
|
||||
}
|
||||
// This math doesn't take into account the number of items loaded _after_ the pivot.
|
||||
|
@ -244,7 +244,7 @@ public class ConversationMessageMapping: NSObject {
|
|||
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
|
||||
|
||||
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
|
||||
owsFailDebug("Couldn't find interaction.")
|
||||
SNLog("Couldn't find interaction.")
|
||||
return nil
|
||||
}
|
||||
return IndexPath(row: newIndex, section: 0)
|
||||
|
|
|
@ -39,6 +39,26 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
unreadViewItems.removeAll()
|
||||
messagesTableView.scrollToRow(at: indexPath, at: .top, animated: true)
|
||||
}
|
||||
|
||||
// MARK: Call
|
||||
@objc func startCall(_ sender: Any?) {
|
||||
guard SessionCall.isEnabled else { return }
|
||||
if SSKPreferences.areCallsEnabled {
|
||||
requestMicrophonePermissionIfNeeded { }
|
||||
guard AVAudioSession.sharedInstance().recordPermission == .granted else { return }
|
||||
guard let contactSessionID = (thread as? TSContactThread)?.contactSessionID() else { return }
|
||||
guard AppEnvironment.shared.callManager.currentCall == nil else { return }
|
||||
let call = SessionCall(for: contactSessionID, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true)
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = self
|
||||
self.inputAccessoryView?.isHidden = true
|
||||
self.inputAccessoryView?.alpha = 0
|
||||
present(callVC, animated: true, completion: nil)
|
||||
} else {
|
||||
let callPermissionRequestModal = CallPermissionRequestModal()
|
||||
self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Blocking
|
||||
@objc func unblock() {
|
||||
|
@ -48,21 +68,25 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
self.blockedBanner.alpha = 0
|
||||
}, completion: { _ in
|
||||
if let contact: Contact = Storage.shared.getContact(with: publicKey) {
|
||||
Storage.shared.write { transaction in
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
}
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction else { return }
|
||||
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
|
||||
})
|
||||
}
|
||||
|
||||
func showBlockedModalIfNeeded() -> Bool {
|
||||
guard let thread = thread as? TSContactThread else { return false }
|
||||
let publicKey = thread.contactSessionID()
|
||||
guard OWSBlockingManager.shared().isRecipientIdBlocked(publicKey) else { return false }
|
||||
let blockedModal = BlockedModal(publicKey: publicKey)
|
||||
guard let thread = thread as? TSContactThread, thread.isBlocked() else { return false }
|
||||
|
||||
let blockedModal = BlockedModal(publicKey: thread.contactSessionID())
|
||||
blockedModal.modalPresentationStyle = .overFullScreen
|
||||
blockedModal.modalTransitionStyle = .crossDissolve
|
||||
present(blockedModal, animated: true, completion: nil)
|
||||
|
@ -175,7 +199,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
} catch {
|
||||
let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||
return present(alert, animated: true, completion: nil)
|
||||
return presentAlert(alert)
|
||||
}
|
||||
let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
|
||||
guard urlResourceValues.isDirectory != true else {
|
||||
|
@ -515,7 +539,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
}
|
||||
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
|
||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
||||
let caller = (thread as! TSContactThread).name()
|
||||
let callMissedTipsModal = CallMissedTipsModal(caller: caller)
|
||||
present(callMissedTipsModal, animated: true, completion: nil)
|
||||
} else if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
|
||||
// Show the failed message sheet
|
||||
showFailedMessageSheet(for: message)
|
||||
} else {
|
||||
|
@ -574,6 +602,12 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
// Open the document if possible
|
||||
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
|
||||
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
case .textOnlyMessage:
|
||||
|
@ -635,7 +669,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}))
|
||||
}
|
||||
}
|
||||
present(sheet, animated: true, completion: nil)
|
||||
presentAlert(sheet)
|
||||
}
|
||||
|
||||
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) {
|
||||
|
@ -765,7 +799,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
OpenGroupAPIV2.ban(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
|
||||
present(alert, animated: true, completion: nil)
|
||||
presentAlert(alert)
|
||||
}
|
||||
|
||||
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) {
|
||||
|
@ -779,7 +813,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
OpenGroupAPIV2.banAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
|
||||
present(alert, animated: true, completion: nil)
|
||||
presentAlert(alert)
|
||||
}
|
||||
|
||||
func handleQuoteViewCancelButtonTapped() {
|
||||
|
@ -987,94 +1021,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Requesting Permission
|
||||
func requestCameraPermissionIfNeeded() -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: return true
|
||||
case .denied, .restricted:
|
||||
let modal = PermissionMissingModal(permission: "camera") { }
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
return false
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in })
|
||||
return false
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) {
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted: break
|
||||
case .denied:
|
||||
onNotGranted()
|
||||
let modal = PermissionMissingModal(permission: "microphone") {
|
||||
onNotGranted()
|
||||
}
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
case .undetermined:
|
||||
onNotGranted()
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) {
|
||||
let authorizationStatus: PHAuthorizationStatus
|
||||
if #available(iOS 14, *) {
|
||||
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
if authorizationStatus == .notDetermined {
|
||||
// When the user chooses to select photos (which is the .limit status),
|
||||
// the PHPhotoUI will present the picker view on the top of the front view.
|
||||
// Since we have the ScreenLockUI showing when we request premissions,
|
||||
// the picker view will be presented on the top of the ScreenLockUI.
|
||||
// However, the ScreenLockUI will dismiss with the permission request alert view, so
|
||||
// the picker view then will dismiss, too. The selection process cannot be finished
|
||||
// this way. So we add a flag (isRequestingPermission) to prevent the ScreenLockUI
|
||||
// from showing when we request the photo library permission.
|
||||
Environment.shared.isRequestingPermission = true
|
||||
let appMode = AppModeManager.shared.currentAppMode
|
||||
// FIXME: Rather than setting the app mode to light and then to dark again once we're done,
|
||||
// it'd be better to just customize the appearance of the image picker. There doesn't currently
|
||||
// appear to be a good way to do so though...
|
||||
AppModeManager.shared.setCurrentAppMode(to: .light)
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
|
||||
DispatchQueue.main.async {
|
||||
AppModeManager.shared.setCurrentAppMode(to: appMode)
|
||||
}
|
||||
Environment.shared.isRequestingPermission = false
|
||||
if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) {
|
||||
onAuthorized()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authorizationStatus = PHPhotoLibrary.authorizationStatus()
|
||||
if authorizationStatus == .notDetermined {
|
||||
PHPhotoLibrary.requestAuthorization { status in
|
||||
if status == .authorized {
|
||||
onAuthorized()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch authorizationStatus {
|
||||
case .authorized, .limited:
|
||||
onAuthorized()
|
||||
case .denied, .restricted:
|
||||
let modal = PermissionMissingModal(permission: "library") { }
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
|
||||
let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "")
|
||||
let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage
|
||||
|
@ -1153,6 +1100,9 @@ extension ConversationVC {
|
|||
Storage.shared.setContact(contact, using: transaction)
|
||||
}
|
||||
|
||||
// Send a sync message with the details of the contact
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
// Hide the 'messageRequestView' since the request has been approved and force a config
|
||||
// sync to propagate the contact approval state (both must run on the main thread)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
|
@ -1186,11 +1136,6 @@ extension ConversationVC {
|
|||
newViewControllers.remove(at: messageRequestsIndex)
|
||||
self?.navigationController?.setViewControllers(newViewControllers, animated: false)
|
||||
}
|
||||
|
||||
// Send a sync message with the details of the contact
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1227,6 +1172,12 @@ extension ConversationVC {
|
|||
let sessionId: String = contactThread.contactSessionID()
|
||||
|
||||
if let contact: Contact = Storage.shared.getContact(with: sessionId) {
|
||||
// Stop observing the `BlockListDidChange` notification (we are about to pop the screen
|
||||
// so showing the banner just looks buggy)
|
||||
if let strongSelf = self {
|
||||
NotificationCenter.default.removeObserver(strongSelf, name: .contactBlockedStateChanged, object: nil)
|
||||
}
|
||||
|
||||
contact.isApproved = false
|
||||
contact.isBlocked = true
|
||||
|
||||
|
@ -1244,24 +1195,10 @@ extension ConversationVC {
|
|||
self?.thread.remove(with: transaction)
|
||||
},
|
||||
completion: { [weak self] in
|
||||
// Block the contact
|
||||
if let sessionId: String = (self?.thread as? TSContactThread)?.contactSessionID(), !OWSBlockingManager.shared().isRecipientIdBlocked(sessionId) {
|
||||
|
||||
// Stop observing the `BlockListDidChange` notification (we are about to pop the screen
|
||||
// so showing the banner just looks buggy)
|
||||
if let strongSelf = self {
|
||||
NotificationCenter.default.removeObserver(strongSelf, name: NSNotification.Name(rawValue: kNSNotificationName_BlockListDidChange), object: nil)
|
||||
}
|
||||
|
||||
OWSBlockingManager.shared().addBlockedPhoneNumber(sessionId)
|
||||
}
|
||||
// Force a config sync and pop to the previous screen
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
// Force a config sync and pop to the previous screen (both must run on the main thread)
|
||||
DispatchQueue.main.async {
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
|
||||
}
|
||||
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -331,13 +331,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
|
||||
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
|
||||
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
||||
|
@ -359,7 +354,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleAudioDidFinishPlayingNotification(_:)), name: .SNAudioDidFinishPlaying, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: NSNotification.Name(rawValue: kNSNotificationName_BlockListDidChange), object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: .contactBlockedStateChanged, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleGroupUpdatedNotification), name: .groupThreadUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(sendScreenshotNotificationIfNeeded), name: UIApplication.userDidTakeScreenshotNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleMessageSentStatusChanged), name: .messageSentStatusDidChange, object: nil)
|
||||
|
@ -425,6 +420,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
highlightFocusedMessageIfNeeded()
|
||||
didFinishInitialLayout = true
|
||||
markAllAsRead()
|
||||
self.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
|
@ -433,7 +429,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
Storage.write { transaction in
|
||||
self.thread.setDraft(text, transaction: transaction)
|
||||
}
|
||||
inputAccessoryView?.resignFirstResponder()
|
||||
self.resignFirstResponder()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
|
@ -441,6 +437,12 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
mediaCache.removeAllObjects()
|
||||
}
|
||||
|
||||
override func appDidBecomeActive(_ notification: Notification) {
|
||||
// This is a workaround for an issue where the textview is not scrollable
|
||||
// after the app goes into background and goes back in foreground.
|
||||
self.snInputView.text = self.snInputView.text
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
@ -469,38 +471,42 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
navigationItem.rightBarButtonItems = []
|
||||
}
|
||||
else {
|
||||
var rightBarButtonItems: [UIBarButtonItem] = []
|
||||
if let contactThread: TSContactThread = thread as? TSContactThread {
|
||||
// Don't show the settings button for message requests
|
||||
if let contact: Contact = Storage.shared.getContact(with: contactThread.contactSessionID()), contact.isApproved, contact.didApproveMe {
|
||||
let size = Values.verySmallProfilePictureSize
|
||||
let profilePictureView = ProfilePictureView()
|
||||
profilePictureView.accessibilityLabel = "Settings button"
|
||||
profilePictureView.size = size
|
||||
profilePictureView.update(for: thread)
|
||||
profilePictureView.set(.width, to: size)
|
||||
profilePictureView.set(.height, to: size)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
||||
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView)
|
||||
rightBarButtonItem.accessibilityLabel = "Settings button"
|
||||
rightBarButtonItem.isAccessibilityElement = true
|
||||
|
||||
navigationItem.rightBarButtonItem = rightBarButtonItem
|
||||
let settingsButton = UIBarButtonItem(customView: profilePictureView)
|
||||
settingsButton.accessibilityLabel = "Settings button"
|
||||
settingsButton.isAccessibilityElement = true
|
||||
rightBarButtonItems.append(settingsButton)
|
||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isMessageRequest()
|
||||
if shouldShowCallButton {
|
||||
let callButton = UIBarButtonItem(image: UIImage(named: "Phone")!, style: .plain, target: self, action: #selector(startCall))
|
||||
rightBarButtonItems.append(callButton)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Note: Adding an empty button because without it the title alignment is busted (Note: The size was
|
||||
// taken from the layout inspector for the back button in Xcode
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 37, height: 44)))
|
||||
rightBarButtonItems.append(UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 37, height: 44))))
|
||||
}
|
||||
}
|
||||
else {
|
||||
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
|
||||
rightBarButtonItem.accessibilityLabel = "Settings button"
|
||||
rightBarButtonItem.isAccessibilityElement = true
|
||||
|
||||
navigationItem.rightBarButtonItem = rightBarButtonItem
|
||||
let settingsButton = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
|
||||
settingsButton.accessibilityLabel = "Settings button"
|
||||
settingsButton.isAccessibilityElement = true
|
||||
rightBarButtonItems.append(settingsButton)
|
||||
}
|
||||
navigationItem.rightBarButtonItems = rightBarButtonItems
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -722,10 +728,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
blockedBanner.removeFromSuperview()
|
||||
}
|
||||
guard let thread = thread as? TSContactThread else { return detach() }
|
||||
if OWSBlockingManager.shared().isRecipientIdBlocked(thread.contactSessionID()) {
|
||||
if thread.isBlocked() {
|
||||
view.addSubview(blockedBanner)
|
||||
blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
detach()
|
||||
}
|
||||
}
|
||||
|
@ -829,7 +836,32 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
// Search bar
|
||||
let searchBar = searchController.uiSearchController.searchBar
|
||||
searchBar.setUpSessionStyle()
|
||||
navigationItem.titleView = searchBar
|
||||
|
||||
let searchBarContainer = UIView()
|
||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
// On iPad, the cancel button won't show
|
||||
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
|
||||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(hideSearchUI(_ :)), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
// Nav bar buttons
|
||||
updateNavBarButtons()
|
||||
// Hack so that the ResultsBar stays on the screen when dismissing the search field
|
||||
|
@ -863,7 +895,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
navBar.stubbedNextResponder = self
|
||||
}
|
||||
|
||||
func hideSearchUI() {
|
||||
@objc func hideSearchUI(_ sender: Any? = nil) {
|
||||
isShowingSearchUI = false
|
||||
navigationItem.titleView = titleView
|
||||
updateNavBarButtons()
|
||||
|
|
|
@ -131,7 +131,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
|
|||
|
||||
- (void)copyMediaAction;
|
||||
- (void)copyTextAction;
|
||||
- (void)shareMediaAction;
|
||||
- (void)saveMediaAction;
|
||||
- (void)deleteLocallyAction;
|
||||
- (void)deleteRemotelyAction;
|
||||
|
|
|
@ -32,6 +32,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
|
|||
return @"OWSMessageCellType_MediaMessage";
|
||||
case OWSMessageCellType_OversizeTextDownloading:
|
||||
return @"OWSMessageCellType_OversizeTextDownloading";
|
||||
case OWSMessageCellType_DeletedMessage:
|
||||
return @"OWSMessageCellType_DeletedMessage";
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -798,42 +800,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
|
|||
[UIPasteboard.generalPasteboard setData:data forPasteboardType:utiType];
|
||||
}
|
||||
|
||||
- (void)shareMediaAction
|
||||
{
|
||||
if (self.attachmentPointer != nil) {
|
||||
OWSFailDebug(@"Can't share not-yet-downloaded attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (self.messageCellType) {
|
||||
case OWSMessageCellType_Unknown:
|
||||
case OWSMessageCellType_TextOnlyMessage:
|
||||
case OWSMessageCellType_Audio:
|
||||
case OWSMessageCellType_GenericAttachment:
|
||||
[AttachmentSharing showShareUIForAttachment:self.attachmentStream];
|
||||
break;
|
||||
case OWSMessageCellType_MediaMessage: {
|
||||
// TODO: We need a "canShareMediaAction" method.
|
||||
OWSAssertDebug(self.mediaAlbumItems);
|
||||
NSMutableArray<TSAttachmentStream *> *attachmentStreams = [NSMutableArray new];
|
||||
for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) {
|
||||
if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) {
|
||||
[attachmentStreams addObject:mediaAlbumItem.attachmentStream];
|
||||
}
|
||||
}
|
||||
if (attachmentStreams.count < 1) {
|
||||
OWSFailDebug(@"Can't share media album; no valid items.");
|
||||
return;
|
||||
}
|
||||
[AttachmentSharing showShareUIForAttachments:attachmentStreams completion:nil];
|
||||
break;
|
||||
}
|
||||
case OWSMessageCellType_OversizeTextDownloading:
|
||||
OWSFailDebug(@"Can't share not-yet-downloaded attachment");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)canCopyMedia
|
||||
{
|
||||
if (self.attachmentPointer != nil) {
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
#import <SignalCoreKit/NSDate+OWS.h>
|
||||
#import <SignalUtilitiesKit/OWSUnreadIndicator.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SessionMessagingKit/OWSBlockingManager.h>
|
||||
#import <SessionMessagingKit/OWSPrimaryStorage.h>
|
||||
#import <SessionMessagingKit/SSKEnvironment.h>
|
||||
#import <SessionMessagingKit/TSDatabaseView.h>
|
||||
|
@ -247,11 +246,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
return self.primaryStorage.dbReadWriteConnection;
|
||||
}
|
||||
|
||||
- (OWSBlockingManager *)blockingManager
|
||||
{
|
||||
return OWSBlockingManager.sharedManager;
|
||||
}
|
||||
|
||||
- (id<OWSTypingIndicators>)typingIndicators
|
||||
{
|
||||
return SSKEnvironment.shared.typingIndicators;
|
||||
|
@ -281,13 +275,9 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
selector:@selector(typingIndicatorStateDidChange:)
|
||||
name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange]
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(profileWhitelistDidChange:)
|
||||
name:kNSNotificationName_ProfileWhitelistDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(blockListDidChange:)
|
||||
name:kNSNotificationName_BlockListDidChange
|
||||
name:NSNotification.contactBlockedStateChanged
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(localProfileDidChange:)
|
||||
|
@ -295,14 +285,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
object:nil];
|
||||
}
|
||||
|
||||
- (void)profileWhitelistDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.conversationProfileState = nil;
|
||||
[self updateForTransientItems];
|
||||
}
|
||||
|
||||
- (void)localProfileDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
@ -492,7 +474,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
ThreadDynamicInteractions *dynamicInteractions =
|
||||
[ThreadUtil ensureDynamicInteractionsForThread:self.thread
|
||||
blockingManager:self.blockingManager
|
||||
dbConnection:self.editingDatabaseConnection
|
||||
hideUnreadMessagesIndicator:self.hasClearedUnreadMessagesIndicator
|
||||
lastUnreadIndicator:self.dynamicInteractions.unreadIndicator
|
||||
|
|
|
@ -113,7 +113,7 @@ public class LongTextViewController: OWSViewController {
|
|||
// MARK: - Create Views
|
||||
|
||||
private func createViews() {
|
||||
view.backgroundColor = Theme.backgroundColor
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
let messageTextView = OWSTextView()
|
||||
self.messageTextView = messageTextView
|
||||
|
@ -165,6 +165,13 @@ public class LongTextViewController: OWSViewController {
|
|||
// MARK: - Actions
|
||||
|
||||
@objc func shareButtonPressed() {
|
||||
AttachmentSharing.showShareUI(forText: fullText)
|
||||
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
self.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageCell : MessageCell {
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||
|
||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
|
||||
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result = UIView()
|
||||
result.set(.height, to: 50)
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
label.autoCenterInSuperview()
|
||||
result.addSubview(iconImageView)
|
||||
iconImageView.autoVCenterInSuperview()
|
||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||
result.addSubview(infoImageView)
|
||||
infoImageView.autoVCenterInSuperview()
|
||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
|
||||
override class var identifier: String { "CallMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
container.autoPinWidthToSuperview()
|
||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
|
||||
}
|
||||
|
||||
override func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let icon: UIImage?
|
||||
switch message.callState {
|
||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
||||
default: icon = nil
|
||||
}
|
||||
iconImageView.image = icon
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
|
||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
|
||||
let date = message.dateForUI()
|
||||
let description = DateUtil.formatDate(forDisplay: date)
|
||||
timestampLabel.text = description
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
if shouldBeTappable {
|
||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
final class CallMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
||||
// Image view
|
||||
let iconSize = CallMessageView.iconSize
|
||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = message.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ final class LinkPreviewView : UIView {
|
|||
private lazy var sentLinkPreviewTextColor: UIColor = {
|
||||
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
|
||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||
case (true, .dark), (false, .light): return .black
|
||||
case (false, .light): return .black
|
||||
case (true, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import SessionUIKit
|
||||
|
||||
@objc(OWSMediaView)
|
||||
public class MediaView: UIView {
|
||||
|
@ -149,7 +150,7 @@ public class MediaView: UIView {
|
|||
configure(forError: .missing)
|
||||
return
|
||||
}
|
||||
backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||
|
@ -352,7 +353,7 @@ public class MediaView: UIView {
|
|||
}
|
||||
|
||||
private func configure(forError error: MediaError) {
|
||||
backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let icon: UIImage
|
||||
switch error {
|
||||
case .failed:
|
||||
|
|
|
@ -57,7 +57,11 @@ class MessageCell : UITableViewCell {
|
|||
switch viewItem.interaction {
|
||||
case is TSIncomingMessage: fallthrough
|
||||
case is TSOutgoingMessage: return VisibleMessageCell.self
|
||||
case is TSInfoMessage: return InfoMessageCell.self
|
||||
case is TSInfoMessage:
|
||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
||||
return CallMessageCell.self
|
||||
}
|
||||
return InfoMessageCell.self
|
||||
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
|
||||
default: preconditionFailure()
|
||||
}
|
||||
|
|
|
@ -126,7 +126,13 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
static let largeCornerRadius: CGFloat = 18
|
||||
static let contactThreadHSpacing = Values.mediumSpacing
|
||||
|
||||
static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
|
||||
static var gutterSize: CGFloat = {
|
||||
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
|
||||
if UIDevice.current.isIPad {
|
||||
result += CGFloat(UIScreen.main.bounds.width / 2 - 88)
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
private var bodyLabelTextColor: UIColor {
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
|
@ -258,7 +264,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
messageStatusImageView.tintColor = tintColor
|
||||
messageStatusImageView.backgroundColor = backgroundColor
|
||||
if let message = message as? TSOutgoingMessage {
|
||||
messageStatusImageView.isHidden = (message.messageState == .sent && thread?.lastInteraction != message)
|
||||
messageStatusImageView.isHidden = (message.isCallMessage || message.messageState == .sent && thread?.lastInteraction != message)
|
||||
} else {
|
||||
messageStatusImageView.isHidden = true
|
||||
}
|
||||
|
@ -276,7 +282,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing)
|
||||
timerViewIncomingMessageConstraint.isActive = (direction == .incoming)
|
||||
// Swipe to reply
|
||||
if (message.isDeleted) {
|
||||
if (message.isDeleted || message.isCallMessage) {
|
||||
removeGestureRecognizer(panGestureRecognizer)
|
||||
} else {
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
|
@ -319,6 +325,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset
|
||||
if let linkPreview = viewItem.linkPreview {
|
||||
let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self)
|
||||
linkPreviewView.layer.mask = bubbleViewMaskLayer
|
||||
linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment)
|
||||
snContentView.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(to: snContentView)
|
||||
|
@ -326,6 +333,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
self.bodyTextView = linkPreviewView.bodyTextView
|
||||
} else if let openGroupInvitationName = message.openGroupInvitationName, let openGroupInvitationURL = message.openGroupInvitationURL {
|
||||
let openGroupInvitationView = OpenGroupInvitationView(name: openGroupInvitationName, url: openGroupInvitationURL, textColor: bodyLabelTextColor, isOutgoing: isOutgoing)
|
||||
openGroupInvitationView.layer.mask = bubbleViewMaskLayer
|
||||
snContentView.addSubview(openGroupInvitationView)
|
||||
openGroupInvitationView.pin(to: snContentView)
|
||||
openGroupInvitationView.layer.mask = bubbleViewMaskLayer
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
//
|
||||
|
||||
#import "OWSConversationSettingsViewController.h"
|
||||
#import "OWSBlockingManager.h"
|
||||
#import "OWSSoundSettingsViewController.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "UIFont+OWS.h"
|
||||
|
@ -37,7 +36,6 @@ CGFloat kIconViewLength = 24;
|
|||
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
|
||||
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
|
||||
@property (nullable, nonatomic) MediaGallery *mediaGallery;
|
||||
@property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper;
|
||||
@property (nonatomic, readonly) UIImageView *avatarView;
|
||||
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
|
||||
@property (nonatomic) UILabel *displayNameLabel;
|
||||
|
@ -107,11 +105,6 @@ CGFloat kIconViewLength = 24;
|
|||
return SSKEnvironment.shared.tsAccountManager;
|
||||
}
|
||||
|
||||
- (OWSBlockingManager *)blockingManager
|
||||
{
|
||||
return [OWSBlockingManager sharedManager];
|
||||
}
|
||||
|
||||
- (OWSProfileManager *)profileManager
|
||||
{
|
||||
return [OWSProfileManager sharedManager];
|
||||
|
@ -601,7 +594,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *blockConversationSwitch = [UISwitch new];
|
||||
blockConversationSwitch.on = [strongSelf.blockingManager isThreadBlocked:strongSelf.thread];
|
||||
blockConversationSwitch.on = strongSelf.thread.isBlocked;
|
||||
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = blockConversationSwitch;
|
||||
|
@ -868,9 +861,13 @@ CGFloat kIconViewLength = 24;
|
|||
if (![sender isKindOfClass:[UISwitch class]]) {
|
||||
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
||||
}
|
||||
if (![self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
OWSFailDebug(@"unexpected thread type: %@", self.thread.class);
|
||||
}
|
||||
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
||||
TSContactThread *contactThread = (TSContactThread *)self.thread;
|
||||
|
||||
BOOL isCurrentlyBlocked = [self.blockingManager isThreadBlocked:self.thread];
|
||||
BOOL isCurrentlyBlocked = contactThread.isBlocked;
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
if (blockConversationSwitch.isOn) {
|
||||
|
@ -878,12 +875,16 @@ CGFloat kIconViewLength = 24;
|
|||
if (isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showBlockThreadActionSheet:self.thread
|
||||
fromViewController:self
|
||||
blockingManager:self.blockingManager
|
||||
[BlockListUIUtils showBlockThreadActionSheet:contactThread
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
// If we successfully blocked then force a config sync
|
||||
if (isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
}];
|
||||
|
@ -893,12 +894,16 @@ CGFloat kIconViewLength = 24;
|
|||
if (!isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:self.thread
|
||||
fromViewController:self
|
||||
blockingManager:self.blockingManager
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:contactThread
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
// If we successfully unblocked then force a config sync
|
||||
if (!isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
}];
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import SessionMessagingKit
|
||||
|
||||
final class BlockedModal : Modal {
|
||||
final class BlockedModal: Modal {
|
||||
private let publicKey: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
@ -22,7 +23,7 @@ final class BlockedModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_blocked_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -50,20 +51,40 @@ final class BlockedModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func unblock() {
|
||||
OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
|
||||
let publicKey: String = self.publicKey
|
||||
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
||||
return
|
||||
}
|
||||
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction as Any)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
@objc
|
||||
final class CallModal : Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areCallsEnabled = true
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallPermissionRequestModal : Modal {
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_permission_request_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_permission_request_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let goToSettingsButton = UIButton()
|
||||
goToSettingsButton.set(.height, to: Values.mediumButtonHeight)
|
||||
goToSettingsButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
goToSettingsButton.backgroundColor = Colors.buttonBackground
|
||||
goToSettingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
goToSettingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
goToSettingsButton.setTitle(NSLocalizedString("vc_settings_title", comment: ""), for: UIControl.State.normal)
|
||||
goToSettingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, goToSettingsButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func goToSettings(_ sender: Any) {
|
||||
dismiss(animated: true, completion: {
|
||||
if let vc = CurrentAppContext().frontmostViewController() {
|
||||
let privacySettingsVC = PrivacySettingsTableViewController()
|
||||
privacySettingsVC.shouldShowCloseButton = true
|
||||
let nav = OWSNavigationController(rootViewController: privacySettingsVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
vc.present(nav, animated: true, completion: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -23,6 +23,14 @@ final class ConversationTitleView : UIView {
|
|||
result.lineBreakMode = .byTruncatingTail
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(thread: TSThread) {
|
||||
|
@ -40,11 +48,6 @@ final class ConversationTitleView : UIView {
|
|||
}
|
||||
|
||||
private func initialize() {
|
||||
let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
|
@ -67,6 +70,11 @@ final class ConversationTitleView : UIView {
|
|||
subtitleLabel.attributedText = subtitle
|
||||
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
|
||||
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
|
||||
|
||||
// Update title left margin
|
||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread() && !thread.isMessageRequest()
|
||||
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
|
|
|
@ -23,7 +23,7 @@ final class DownloadAttachmentModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -41,7 +41,6 @@ final class DownloadAttachmentModal : Modal {
|
|||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
downloadButton.backgroundColor = Colors.buttonBackground
|
||||
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||
|
@ -51,15 +50,20 @@ final class DownloadAttachmentModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -22,7 +22,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Join \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -50,15 +50,20 @@ final class JoinOpenGroupModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
@ -66,19 +71,18 @@ final class JoinOpenGroupModal : Modal {
|
|||
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else {
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
return presentingViewController!.present(alert, animated: true, completion: nil)
|
||||
return presentingViewController!.presentAlert(alert)
|
||||
}
|
||||
presentingViewController!.dismiss(animated: true, completion: nil)
|
||||
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in
|
||||
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
let appDelegate = UIApplication.shared.delegate as! AppDelegate
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
presentingViewController.present(alert, animated: true, completion: nil)
|
||||
presentingViewController.presentAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ final class LinkPreviewModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -46,15 +46,20 @@ final class LinkPreviewModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -14,6 +14,7 @@ final class MessagesTableView : UITableView {
|
|||
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
|
||||
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
|
||||
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
|
||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
||||
separatorStyle = .none
|
||||
backgroundColor = .clear
|
||||
showsVerticalScrollIndicator = false
|
||||
|
|
|
@ -22,7 +22,7 @@ final class PermissionMissingModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Session"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -50,15 +50,20 @@ final class PermissionMissingModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -5,7 +5,7 @@ final class SendSeedModal : Modal {
|
|||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = NSLocalizedString("modal_send_seed_title", comment: "")
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
|
@ -44,19 +44,27 @@ final class SendSeedModal : Modal {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, buttonStackView ])
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func populateContentView() {
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -20,7 +20,7 @@ final class URLModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -48,15 +48,20 @@ final class URLModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -73,7 +73,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
let tabBarInset: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
tabBarInset = navigationBar.height()
|
||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
||||
} else {
|
||||
tabBarInset = 0
|
||||
}
|
||||
|
@ -177,6 +177,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
weak var NewDMVC: NewDMVC!
|
||||
private var isKeyboardShowing = false
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0
|
||||
|
||||
// MARK: Components
|
||||
private lazy var publicKeyTextView: TextView = {
|
||||
|
@ -212,8 +213,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
private lazy var buttonContainer: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.mediumSpacing
|
||||
result.spacing = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing
|
||||
result.distribution = .fillEqually
|
||||
if (UIDevice.current.isIPad) {
|
||||
result.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin)
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -221,6 +226,8 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
override func viewDidLoad() {
|
||||
// Remove background color
|
||||
view.backgroundColor = .clear
|
||||
// User session id container
|
||||
let userPublicKeyContainer = UIView(wrapping: userPublicKeyLabel, withInsets: .zero, shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth)
|
||||
// Explanation label
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
|
@ -240,14 +247,9 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
let nextButton = Button(style: .prominentOutline, size: .large)
|
||||
nextButton.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal)
|
||||
nextButton.addTarget(self, action: #selector(startNewDMIfPossible), for: UIControl.Event.touchUpInside)
|
||||
let nextButtonContainer = UIView()
|
||||
nextButtonContainer.addSubview(nextButton)
|
||||
nextButton.pin(.leading, to: .leading, of: nextButtonContainer, withInset: 80)
|
||||
nextButton.pin(.top, to: .top, of: nextButtonContainer)
|
||||
nextButtonContainer.pin(.trailing, to: .trailing, of: nextButton, withInset: 80)
|
||||
nextButtonContainer.pin(.bottom, to: .bottom, of: nextButton)
|
||||
let nextButtonContainer = UIView(wrapping: nextButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyLabel, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyContainer, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing)
|
||||
|
@ -256,7 +258,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
mainStackView.pin(.leading, to: .leading, of: view)
|
||||
mainStackView.pin(.top, to: .top, of: view)
|
||||
view.pin(.trailing, to: .trailing, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView, withInset: bottomMargin)
|
||||
// Width constraint
|
||||
view.set(.width, to: UIScreen.main.bounds.width)
|
||||
// Dismiss keyboard on tap
|
||||
|
@ -297,7 +299,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
guard !isKeyboardShowing else { return }
|
||||
isKeyboardShowing = true
|
||||
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
|
||||
bottomConstraint.constant = newHeight
|
||||
bottomConstraint.constant = newHeight + bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 0
|
||||
|
@ -310,7 +312,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
@objc private func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
guard isKeyboardShowing else { return }
|
||||
isKeyboardShowing = false
|
||||
bottomConstraint.constant = 0
|
||||
bottomConstraint.constant = bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 1
|
||||
|
@ -332,6 +334,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
|
||||
@objc private func sharePublicKey() {
|
||||
let shareVC = UIActivityViewController(activityItems: [ getUserHexEncodedPublicKey() ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
NewDMVC.navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
|
||||
|
@ -94,8 +96,23 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
// On iPad, the cancel button won't show
|
||||
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
|
||||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadTableData() {
|
||||
|
@ -150,6 +167,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
||||
Storage.shared.clearRecentSearchResults()
|
||||
}
|
||||
|
||||
@objc func cancel(_ sender: Any) {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_home_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
createNewPrivateChatButton.set(.width, to: Values.iPadButtonWidth)
|
||||
let result = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.mediumSpacing
|
||||
|
@ -154,7 +154,6 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name(kNSNotificationName_LocalProfileDidChange), object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil)
|
||||
// Threads (part 2)
|
||||
threads = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup, TSInboxGroup ], view: TSThreadDatabaseViewExtensionName) // The extension should be registered at this point
|
||||
threads.setIsReversed(true, forGroup: TSInboxGroup)
|
||||
|
@ -179,7 +178,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
let _ = IP2Country.shared.populateCacheIfNeeded()
|
||||
}
|
||||
// Get default open group rooms if needed
|
||||
OpenGroupAPIV2.getDefaultRoomsIfNeeded()
|
||||
OpenGroupAPIV2.getDefaultRoomsIfNeeded()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
@ -187,7 +186,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
reload()
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive(_ notification: Notification) {
|
||||
override func appDidBecomeActive(_ notification: Notification) {
|
||||
reload()
|
||||
}
|
||||
|
||||
|
@ -502,7 +501,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
})
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .default) { _ in })
|
||||
guard let self = self else { return }
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
self.presentAlert(alert)
|
||||
}
|
||||
delete.backgroundColor = Colors.destructive
|
||||
|
||||
|
@ -522,22 +521,52 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
}
|
||||
unpin.backgroundColor = Colors.pathsBuilding
|
||||
|
||||
if let thread = thread as? TSContactThread {
|
||||
if let thread = thread as? TSContactThread, !thread.isNoteToSelf() {
|
||||
let publicKey = thread.contactSessionID()
|
||||
let blockingManager = SSKEnvironment.shared.blockingManager
|
||||
let isBlocked = blockingManager.isRecipientIdBlocked(publicKey)
|
||||
|
||||
let block = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_BLOCK_BUTTON", comment: "")) { _, _ in
|
||||
blockingManager.addBlockedPhoneNumber(publicKey)
|
||||
tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade)
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
||||
return
|
||||
}
|
||||
|
||||
contact.isBlocked = true
|
||||
Storage.shared.setContact(contact, using: transaction as Any)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
block.backgroundColor = Colors.unimportant
|
||||
let unblock = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_UNBLOCK_BUTTON", comment: "")) { _, _ in
|
||||
blockingManager.removeBlockedPhoneNumber(publicKey)
|
||||
tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade)
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
||||
return
|
||||
}
|
||||
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction as Any)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
unblock.backgroundColor = Colors.unimportant
|
||||
return [ delete, (isBlocked ? unblock : block), (isPinned ? unpin : pin) ]
|
||||
} else {
|
||||
return [ delete, (thread.isBlocked() ? unblock : block), (isPinned ? unpin : pin) ]
|
||||
}
|
||||
else {
|
||||
return [ delete, (isPinned ? unpin : pin) ]
|
||||
}
|
||||
}
|
||||
|
@ -598,12 +627,18 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
@objc func joinOpenGroup() {
|
||||
let joinOpenGroupVC = JoinOpenGroupVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: joinOpenGroupVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func createNewDM() {
|
||||
let newDMVC = NewDMVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -611,12 +646,18 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
func createNewDMFromDeepLink(sessionID: String) {
|
||||
let newDMVC = NewDMVC(sessionID: sessionID)
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func createClosedGroup() {
|
||||
let newClosedGroupVC = NewClosedGroupVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newClosedGroupVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -153,8 +153,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.largeSpacing
|
||||
),
|
||||
// Note: The '182' is to match the 'Next' button on the New DM page (which doesn't have a fixed width)
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: 182),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
|
||||
clearAllButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
|
||||
])
|
||||
}
|
||||
|
@ -348,23 +347,23 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
needsSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// Block the contact
|
||||
if
|
||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
||||
!thread.isBlocked(),
|
||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
||||
{
|
||||
contact.isBlocked = true
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
needsSync = true
|
||||
}
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
// Block all the contacts
|
||||
threads.forEach { thread in
|
||||
if let sessionId: String = (thread as? TSContactThread)?.contactSessionID(), !OWSBlockingManager.shared().isRecipientIdBlocked(sessionId) {
|
||||
OWSBlockingManager.shared().addBlockedPhoneNumber(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Force a config sync (must run on the main thread)
|
||||
// Force a config sync
|
||||
if needsSync {
|
||||
DispatchQueue.main.async {
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
|
||||
}
|
||||
}
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -382,19 +381,20 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
with: { [weak self] transaction in
|
||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
||||
self?.updateContactAndThread(thread: thread, with: transaction)
|
||||
|
||||
// Block the contact
|
||||
if
|
||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
||||
!thread.isBlocked(),
|
||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
||||
{
|
||||
contact.isBlocked = true
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
// Block the contact
|
||||
if let sessionId: String = (thread as? TSContactThread)?.contactSessionID(), !OWSBlockingManager.shared().isRecipientIdBlocked(sessionId) {
|
||||
OWSBlockingManager.shared().addBlockedPhoneNumber(sessionId)
|
||||
}
|
||||
|
||||
// Force a config sync (must run on the main thread)
|
||||
DispatchQueue.main.async {
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
|
||||
}
|
||||
}
|
||||
// Force a config sync
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
@ -270,7 +270,7 @@ class GifPickerCell: UICollectionViewCell {
|
|||
|
||||
private func clearViewState() {
|
||||
imageView?.image = nil
|
||||
self.backgroundColor = (Theme.isDarkThemeEnabled
|
||||
self.backgroundColor = (isDarkMode
|
||||
? UIColor(white: 0.25, alpha: 1.0)
|
||||
: UIColor(white: 0.95, alpha: 1.0))
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
import Reachability
|
||||
import SignalUtilitiesKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
@objc
|
||||
protocol GifPickerViewControllerDelegate: class {
|
||||
|
@ -234,7 +234,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
private func createErrorLabel(text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.textColor = Theme.primaryColor
|
||||
label.textColor = Colors.text
|
||||
label.font = UIFont.ows_mediumFont(withSize: 20)
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
|
|
|
@ -379,7 +379,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||
collectionView.reloadData()
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
func clearCollectionViewSelection() {
|
||||
|
@ -551,12 +551,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
let isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
if isSelected {
|
||||
cell.isSelected = isSelected
|
||||
} else {
|
||||
cell.isSelected = isSelected
|
||||
}
|
||||
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
//
|
||||
|
||||
#import "MediaDetailViewController.h"
|
||||
#import "AttachmentSharing.h"
|
||||
#import "ConversationViewItem.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "TSAttachmentStream.h"
|
||||
|
@ -16,6 +15,7 @@
|
|||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SessionUtilitiesKit/NSData+Image.h>
|
||||
#import <SessionUIKit/SessionUIKit.h>
|
||||
#import <YYImage/YYImage.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
@ -201,18 +201,18 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
self.mediaView = animatedView;
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = Theme.offBackgroundColor;
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else if (!self.image) {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = Theme.offBackgroundColor;
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
} else if (self.isVideo) {
|
||||
if (self.attachmentStream.isValidVideo) {
|
||||
self.mediaView = [self buildVideoPlayerView];
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = Theme.offBackgroundColor;
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else {
|
||||
// Present the static image using standard UIImageView
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import UIKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
// Objc wrapper for the MediaGalleryItem struct
|
||||
@objc
|
||||
|
@ -280,7 +281,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
|
||||
lazy var shareBarButton: UIBarButtonItem = {
|
||||
let shareBarButton = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(didPressShare))
|
||||
shareBarButton.tintColor = Theme.darkThemePrimaryColor
|
||||
shareBarButton.tintColor = Colors.text
|
||||
return shareBarButton
|
||||
}()
|
||||
|
||||
|
@ -288,7 +289,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
let deleteBarButton = UIBarButtonItem(barButtonSystemItem: .trash,
|
||||
target: self,
|
||||
action: #selector(didPressDelete))
|
||||
deleteBarButton.tintColor = Theme.darkThemePrimaryColor
|
||||
deleteBarButton.tintColor = Colors.text
|
||||
return deleteBarButton
|
||||
}()
|
||||
|
||||
|
@ -298,14 +299,14 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
|
||||
lazy var videoPlayBarButton: UIBarButtonItem = {
|
||||
let videoPlayBarButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didPressPlayBarButton))
|
||||
videoPlayBarButton.tintColor = Theme.darkThemePrimaryColor
|
||||
videoPlayBarButton.tintColor = Colors.text
|
||||
return videoPlayBarButton
|
||||
}()
|
||||
|
||||
lazy var videoPauseBarButton: UIBarButtonItem = {
|
||||
let videoPauseBarButton = UIBarButtonItem(barButtonSystemItem: .pause, target: self, action:
|
||||
#selector(didPressPauseBarButton))
|
||||
videoPauseBarButton.tintColor = Theme.darkThemePrimaryColor
|
||||
videoPauseBarButton.tintColor = Colors.text
|
||||
return videoPauseBarButton
|
||||
}()
|
||||
|
||||
|
@ -380,8 +381,20 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
let attachmentStream = currentViewController.galleryItem.attachmentStream
|
||||
|
||||
AttachmentSharing.showShareUI(forAttachment: attachmentStream) { activityType in
|
||||
|
||||
let shareVC = UIActivityViewController(activityItems: [ attachmentStream.originalMediaURL! ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in
|
||||
if let activityError = activityError {
|
||||
SNLog("Failed to share with activityError: \(activityError)")
|
||||
} else if completed {
|
||||
SNLog("Did share with activityType: \(activityType.debugDescription)")
|
||||
}
|
||||
guard let activityType = activityType, activityType == .saveToCameraRoll,
|
||||
let tsMessage = currentViewController.galleryItem.message as? TSIncomingMessage, let thread = tsMessage.thread as? TSContactThread else { return }
|
||||
let message = DataExtractionNotification()
|
||||
|
@ -389,8 +402,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
Storage.write { transaction in
|
||||
MessageSender.send(message, in: thread, using: transaction)
|
||||
}
|
||||
|
||||
}
|
||||
self.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import SessionUIKit
|
||||
import UIKit
|
||||
|
||||
public protocol MediaTileViewControllerDelegate: class {
|
||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem)
|
||||
|
@ -75,7 +77,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa
|
|||
let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash,
|
||||
target: self,
|
||||
action: #selector(didPressDelete))
|
||||
deleteButton.tintColor = Theme.darkThemeNavbarIconColor
|
||||
deleteButton.tintColor = Colors.text
|
||||
|
||||
return deleteButton
|
||||
}()
|
||||
|
@ -823,16 +825,16 @@ private class MediaGallerySectionHeader: UICollectionReusableView {
|
|||
|
||||
override init(frame: CGRect) {
|
||||
label = UILabel()
|
||||
label.textColor = Theme.darkThemePrimaryColor
|
||||
label.textColor = Colors.text
|
||||
|
||||
let blurEffect = Theme.darkThemeBarBlurEffect
|
||||
let blurEffect = UIBlurEffect(style: .dark)
|
||||
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
|
||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.backgroundColor = isLightMode ? Colors.cellBackground : Theme.darkThemeNavbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
||||
self.backgroundColor = isLightMode ? Colors.cellBackground : UIColor.ows_black.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
||||
|
||||
self.addSubview(blurEffectView)
|
||||
self.addSubview(label)
|
||||
|
@ -871,7 +873,7 @@ private class MediaGalleryStaticHeader: UICollectionViewCell {
|
|||
|
||||
addSubview(label)
|
||||
|
||||
label.textColor = Theme.darkThemePrimaryColor
|
||||
label.textColor = Colors.text
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
public enum PhotoGridItemType {
|
||||
case photo, animated, video
|
||||
}
|
||||
|
@ -29,7 +32,7 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif")
|
||||
private static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle")
|
||||
|
||||
public var loadingColor = Theme.offBackgroundColor
|
||||
public var loadingColor = Colors.unimportant
|
||||
|
||||
override public var isSelected: Bool {
|
||||
didSet {
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
|
||||
#import "AppDelegate.h"
|
||||
#import "MainAppContext.h"
|
||||
#import "OWSBackup.h"
|
||||
#import "OWSOrphanDataCleaner.h"
|
||||
#import "OWSScreenLockUI.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "SignalApp.h"
|
||||
|
@ -101,11 +99,6 @@ static NSTimeInterval launchStartedAt;
|
|||
return Environment.shared.windowManager;
|
||||
}
|
||||
|
||||
- (OWSBackup *)backup
|
||||
{
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
- (OWSNotificationPresenter *)notificationPresenter
|
||||
{
|
||||
return AppEnvironment.shared.notificationPresenter;
|
||||
|
@ -121,8 +114,13 @@ static NSTimeInterval launchStartedAt;
|
|||
- (void)applicationDidEnterBackground:(UIApplication *)application
|
||||
{
|
||||
[DDLog flushLog];
|
||||
|
||||
[self stopPoller];
|
||||
|
||||
// NOTE: Fix an edge case where user taps on the callkit notification
|
||||
// but answers the call on another device
|
||||
if (![self hasIncomingCallWaiting]) {
|
||||
[self stopPoller];
|
||||
}
|
||||
|
||||
[self stopClosedGroupPoller];
|
||||
[self stopOpenGroupPollers];
|
||||
}
|
||||
|
@ -173,16 +171,16 @@ static NSTimeInterval launchStartedAt;
|
|||
[AppEnvironment.shared setup];
|
||||
[SignalApp.sharedApp setup];
|
||||
}
|
||||
migrationCompletion:^{
|
||||
migrationCompletion:^(BOOL successful, BOOL needsConfigSync){
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self versionMigrationsDidComplete];
|
||||
[self versionMigrationsDidCompleteNeedingConfigSync:needsConfigSync];
|
||||
}];
|
||||
|
||||
[SNConfiguration performMainSetup];
|
||||
|
||||
[SNAppearance switchToSessionAppearance];
|
||||
|
||||
|
||||
if (CurrentAppContext().isRunningTests) {
|
||||
return YES;
|
||||
}
|
||||
|
@ -197,13 +195,11 @@ static NSTimeInterval launchStartedAt;
|
|||
LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault];
|
||||
[self adaptAppMode:appMode];
|
||||
|
||||
if (@available(iOS 11, *)) {
|
||||
// This must happen in appDidFinishLaunching or earlier to ensure we don't
|
||||
// miss notifications.
|
||||
// Setting the delegate also seems to prevent us from getting the legacy notification
|
||||
// notification callbacks upon launch e.g. 'didReceiveLocalNotification'
|
||||
UNUserNotificationCenter.currentNotificationCenter.delegate = self;
|
||||
}
|
||||
// This must happen in appDidFinishLaunching or earlier to ensure we don't
|
||||
// miss notifications.
|
||||
// Setting the delegate also seems to prevent us from getting the legacy notification
|
||||
// notification callbacks upon launch e.g. 'didReceiveLocalNotification'
|
||||
UNUserNotificationCenter.currentNotificationCenter.delegate = self;
|
||||
|
||||
[OWSScreenLockUI.sharedManager setupWithRootWindow:self.window];
|
||||
[[OWSWindowManager sharedManager] setupWithRootWindow:self.window
|
||||
|
@ -219,11 +215,12 @@ static NSTimeInterval launchStartedAt;
|
|||
name:RegistrationStateDidChangeNotification
|
||||
object:nil];
|
||||
|
||||
// Loki - Observe data nuke request notifications
|
||||
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleDataNukeRequested:) name:NSNotification.dataNukeRequested object:nil];
|
||||
|
||||
OWSLogInfo(@"application: didFinishLaunchingWithOptions completed.");
|
||||
|
||||
|
||||
[self setUpCallHandling];
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
@ -406,16 +403,22 @@ static NSTimeInterval launchStartedAt;
|
|||
if (CurrentAppContext().isMainApp) {
|
||||
[SNJobQueue.shared resumePendingJobs];
|
||||
[self syncConfigurationIfNeeded];
|
||||
[self handleAppActivatedWithOngoingCallIfNeeded];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (void)versionMigrationsDidComplete
|
||||
- (void)versionMigrationsDidCompleteNeedingConfigSync:(BOOL)needsConfigSync
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.areVersionMigrationsComplete = YES;
|
||||
|
||||
// If we need a config sync then trigger it now
|
||||
if (needsConfigSync) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[self checkIfAppIsReady];
|
||||
}
|
||||
|
@ -552,11 +555,7 @@ static NSTimeInterval launchStartedAt;
|
|||
UIViewController *rootViewController;
|
||||
BOOL navigationBarHidden = NO;
|
||||
if ([self.tsAccountManager isRegistered]) {
|
||||
if (self.backup.hasPendingRestoreDecision) {
|
||||
rootViewController = [BackupRestoreViewController new];
|
||||
} else {
|
||||
rootViewController = [HomeVC new];
|
||||
}
|
||||
rootViewController = [HomeVC new];
|
||||
} else {
|
||||
rootViewController = [LandingVC new];
|
||||
navigationBarHidden = NO;
|
||||
|
|
|
@ -1,48 +1,174 @@
|
|||
import PromiseKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension AppDelegate {
|
||||
|
||||
// MARK: Call handling
|
||||
@objc func hasIncomingCallWaiting() -> Bool {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall else { return false }
|
||||
return !call.hasStartedConnecting
|
||||
}
|
||||
|
||||
@objc func handleAppActivatedWithOngoingCallIfNeeded() {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall else { return }
|
||||
guard MiniCallView.current == nil else { return }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call == call { return }
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: call)
|
||||
if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func dismissAllCallUI() {
|
||||
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
|
||||
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
|
||||
}
|
||||
|
||||
private func showCallUIForCall(_ call: SessionCall) {
|
||||
DispatchQueue.main.async {
|
||||
call.reportIncomingCallIfNeeded{ error in
|
||||
if let error = error {
|
||||
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
|
||||
} else {
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID {
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
} else if !SSKPreferences.isCallKitSupported {
|
||||
let incomingCallBanner = IncomingCallBanner(for: call)
|
||||
incomingCallBanner.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertCallInfoMessage(for message: CallMessage, using transaction: YapDatabaseReadWriteTransaction) -> TSInfoMessage {
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save(with: transaction)
|
||||
return infoMessage
|
||||
}
|
||||
|
||||
private func showMissedCallTipsIfNeeded(caller: String) {
|
||||
let userDefaults = UserDefaults.standard
|
||||
guard !userDefaults[.hasSeenCallMissedTips] else { return }
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() }
|
||||
let callMissedTipsModal = CallMissedTipsModal(caller: caller)
|
||||
presentingVC.present(callMissedTipsModal, animated: true, completion: nil)
|
||||
userDefaults[.hasSeenCallMissedTips] = true
|
||||
}
|
||||
|
||||
@objc func setUpCallHandling() {
|
||||
// Pre offer messages
|
||||
MessageReceiver.handleNewCallOfferMessageIfNeeded = { (message, transaction) in
|
||||
guard CurrentAppContext().isMainApp else { return }
|
||||
guard let timestamp = message.sentTimestamp, TimestampUtils.isWithinOneMinute(timestamp: timestamp) else {
|
||||
// Add missed call message for call offer messages from more than one minute
|
||||
let infoMessage = self.insertCallInfoMessage(for: message, using: transaction)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction)
|
||||
return
|
||||
}
|
||||
guard SSKPreferences.areCallsEnabled else {
|
||||
let infoMessage = self.insertCallInfoMessage(for: message, using: transaction)
|
||||
infoMessage.updateCallInfoMessage(.permissionDenied, using: transaction)
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction)
|
||||
let contactName = Storage.shared.getContact(with: message.sender!, using: transaction)?.displayName(for: Contact.Context.regular) ?? message.sender!
|
||||
DispatchQueue.main.async {
|
||||
self.showMissedCallTipsIfNeeded(caller: contactName)
|
||||
}
|
||||
return
|
||||
}
|
||||
let callManager = AppEnvironment.shared.callManager
|
||||
// Ignore pre offer message after the same call instance has been generated
|
||||
if let currentCall = callManager.currentCall, currentCall.uuid == message.uuid! { return }
|
||||
guard callManager.currentCall == nil else {
|
||||
callManager.handleIncomingCallOfferInBusyState(offerMessage: message, using: transaction)
|
||||
return
|
||||
}
|
||||
let infoMessage = self.insertCallInfoMessage(for: message, using: transaction)
|
||||
// Handle UI
|
||||
if let caller = message.sender, let uuid = message.uuid {
|
||||
let call = SessionCall(for: caller, uuid: uuid, mode: .answer)
|
||||
call.callMessageID = infoMessage.uniqueId
|
||||
self.showCallUIForCall(call)
|
||||
}
|
||||
}
|
||||
// Offer messages
|
||||
MessageReceiver.handleOfferCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
let sdp = RTCSessionDescription(type: .offer, sdp: message.sdps![0])
|
||||
call.didReceiveRemoteSDP(sdp: sdp)
|
||||
}
|
||||
}
|
||||
// Answer messages
|
||||
MessageReceiver.handleAnswerCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
if message.sender! == getUserHexEncodedPublicKey() {
|
||||
guard !call.hasStartedConnecting else { return }
|
||||
self.dismissAllCallUI()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .answeredElsewhere)
|
||||
} else {
|
||||
call.hasStartedConnecting = true
|
||||
let sdp = RTCSessionDescription(type: .answer, sdp: message.sdps![0])
|
||||
call.didReceiveRemoteSDP(sdp: sdp)
|
||||
guard let callVC = CurrentAppContext().frontmostViewController() as? CallVC else { return }
|
||||
callVC.handleAnswerMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
// End call messages
|
||||
MessageReceiver.handleEndCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
self.dismissAllCallUI()
|
||||
if message.sender! == getUserHexEncodedPublicKey() {
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .declinedElsewhere)
|
||||
} else {
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Configuration message
|
||||
@objc(syncConfigurationIfNeeded)
|
||||
func syncConfigurationIfNeeded() {
|
||||
guard Storage.shared.getUser()?.name != nil else { return }
|
||||
let userDefaults = UserDefaults.standard
|
||||
let lastSync = userDefaults[.lastConfigurationSync] ?? .distantPast
|
||||
guard Date().timeIntervalSince(lastSync) > 7 * 24 * 60 * 60 else { return } // Sync every 2 days
|
||||
let destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey())
|
||||
Storage.write { transaction in
|
||||
guard let configurationMessage = ConfigurationMessage.getCurrent(with: transaction) else { return }
|
||||
|
||||
let job = MessageSendJob(message: configurationMessage, destination: destination)
|
||||
JobQueue.shared.add(job, using: transaction)
|
||||
}
|
||||
|
||||
// Only update the 'lastConfigurationSync' timestamp if we have done the first sync (Don't want
|
||||
// a new device config sync to override config syncs from other devices)
|
||||
if userDefaults[.hasSyncedInitialConfiguration] {
|
||||
userDefaults[.lastConfigurationSync] = Date()
|
||||
}
|
||||
}
|
||||
|
||||
func forceSyncConfigurationNowIfNeeded() -> Promise<Void> {
|
||||
let destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey())
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
// Note: SQLite only supports a single write thread so we can be sure this will retrieve the most up-to-date data
|
||||
Storage.writeSync { transaction in
|
||||
guard Storage.shared.getUser(using: transaction)?.name != nil, let configurationMessage = ConfigurationMessage.getCurrent(with: transaction) else {
|
||||
seal.fulfill(())
|
||||
return
|
||||
MessageSender.syncConfiguration(forceSyncNow: false)
|
||||
.done {
|
||||
// Only update the 'lastConfigurationSync' timestamp if we have done the first sync (Don't want
|
||||
// a new device config sync to override config syncs from other devices)
|
||||
if userDefaults[.hasSyncedInitialConfiguration] {
|
||||
userDefaults[.lastConfigurationSync] = Date()
|
||||
}
|
||||
}
|
||||
|
||||
MessageSender.send(configurationMessage, to: destination, using: transaction).done {
|
||||
seal.fulfill(())
|
||||
}.catch { _ in
|
||||
seal.fulfill(()) // Fulfill even if this failed; the configuration in the swarm should be at most 2 days old
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
return promise
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
// MARK: Closed group poller
|
||||
@objc func startClosedGroupPoller() {
|
||||
guard OWSIdentityManager.shared().identityKeyPair() != nil else { return }
|
||||
ClosedGroupPoller.shared.start()
|
||||
|
@ -51,4 +177,5 @@ extension AppDelegate {
|
|||
@objc func stopClosedGroupPoller() {
|
||||
ClosedGroupPoller.shared.stop()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -27,6 +27,9 @@ import SignalUtilitiesKit
|
|||
|
||||
@objc
|
||||
public var accountManager: AccountManager
|
||||
|
||||
@objc
|
||||
public var callManager: SessionCallManager
|
||||
|
||||
@objc
|
||||
public var notificationPresenter: NotificationPresenter
|
||||
|
@ -34,9 +37,6 @@ import SignalUtilitiesKit
|
|||
@objc
|
||||
public var pushRegistrationManager: PushRegistrationManager
|
||||
|
||||
@objc
|
||||
public var backup: OWSBackup
|
||||
|
||||
@objc
|
||||
public var fileLogger: DDFileLogger
|
||||
|
||||
|
@ -49,15 +49,11 @@ import SignalUtilitiesKit
|
|||
return _userNotificationActionHandler as! UserNotificationActionHandler
|
||||
}
|
||||
|
||||
@objc
|
||||
public var backupLazyRestore: BackupLazyRestore
|
||||
|
||||
private override init() {
|
||||
self.accountManager = AccountManager()
|
||||
self.callManager = SessionCallManager()
|
||||
self.notificationPresenter = NotificationPresenter()
|
||||
self.pushRegistrationManager = PushRegistrationManager()
|
||||
self.backup = OWSBackup()
|
||||
self.backupLazyRestore = BackupLazyRestore()
|
||||
self._userNotificationActionHandler = UserNotificationActionHandler()
|
||||
self.fileLogger = DDFileLogger()
|
||||
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Airpods.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AnswerCall.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "audio_off_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bluetooth.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallIncoming.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallMissed.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallOutgoing.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "check.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Path.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Headsets.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "minimize.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Phone.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "speaker.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue