Merge pull request #616 from oxen-io/dev

1.12.7 Voice & Video Calls!
This commit is contained in:
RyanZhao 2022-04-26 11:07:52 +10:00 committed by GitHub
commit e0cecc09e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
284 changed files with 6437 additions and 10203 deletions

1
.gitignore vendored
View File

@ -27,7 +27,6 @@ DerivedData
*.ipa
*.xcuserstate
Index/
Session-Turn-Server
# CocoaPods
Pods

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
})
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

550
Session/Calls/CallVC.swift Normal file
View File

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

View File

@ -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.")
}
}

View File

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

View File

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

View File

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

View File

@ -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()
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,7 +131,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
- (void)copyMediaAction;
- (void)copyTextAction;
- (void)shareMediaAction;
- (void)saveMediaAction;
- (void)deleteLocallyAction;
- (void)deleteRemotelyAction;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}
)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}
}

View File

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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Airpods.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AnswerCall.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "audio_off_fill.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Bluetooth.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallIncoming.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallMissed.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallOutgoing.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "check.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Path.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Headsets.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "minimize.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Phone.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

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