Merge remote-tracking branch 'upstream/voice-calls-2' into feature/session-id-blinding-part-2
# Conflicts: # Podfile.lock # Session.xcodeproj/project.pbxproj # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/Views & Modals/JoinOpenGroupModal.swift # Session/Home/HomeVC.swift # Session/Open Groups/JoinOpenGroupVC.swift # Session/Utilities/BackgroundPoller.swift # SessionMessagingKit/Jobs/AttachmentUploadJob.swift # SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift # SessionNotificationServiceExtension/NotificationServiceExtension.swift
This commit is contained in:
commit
1c575e520d
20
Podfile
20
Podfile
|
@ -11,6 +11,9 @@ abstract_target 'GlobalDependencies' do
|
|||
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
|
||||
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||
# FIXME: If 'GoogleWebRTC' ever properly supports the arm64 simulators then remove the 'set_simulators_to_run_x86' post install step
|
||||
pod 'GoogleWebRTC'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
||||
target 'Session' do
|
||||
pod 'AFNetworking'
|
||||
|
@ -84,6 +87,7 @@ target 'SessionUIKit'
|
|||
post_install do |installer|
|
||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
set_minimum_deployment_target(installer)
|
||||
set_simulators_to_run_x86(installer)
|
||||
end
|
||||
|
||||
def enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
|
@ -104,3 +108,19 @@ def set_minimum_deployment_target(installer)
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Note: This is needed in order to build with the 'GoogleWebRTC' framework on M1 macs as well
|
||||
# as to allow it to run on the iOS simulator as they don't include an iOS arm64 simulator slice
|
||||
# in the framework (see https://stackoverflow.com/a/66094347 for more info and also
|
||||
# https://blog.sudeium.com/2021/06/18/build-for-x86-simulator-on-apple-silicon-macs/)
|
||||
#
|
||||
# Accoring to https://github.com/react-native-webrtc/react-native-webrtc/issues/1033 it also doesn't
|
||||
# support Catalyst at the moment so changes/updates would be needed if we wanted to add support
|
||||
def set_simulators_to_run_x86(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
# Force CocoaPods targets to always build for x86_64
|
||||
config.build_settings['ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
10
Podfile.lock
10
Podfile.lock
|
@ -21,6 +21,7 @@ PODS:
|
|||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
- GoogleWebRTC (1.1.31999)
|
||||
- Mantle (2.1.0):
|
||||
- Mantle/extobjc (= 2.1.0)
|
||||
- Mantle/extobjc (2.1.0)
|
||||
|
@ -45,6 +46,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)
|
||||
|
@ -125,6 +127,7 @@ DEPENDENCIES:
|
|||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- GoogleWebRTC
|
||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
||||
- Nimble (from `https://github.com/Quick/Nimble`, commit `cabe966`)
|
||||
- NVActivityIndicatorView
|
||||
|
@ -134,6 +137,7 @@ DEPENDENCIES:
|
|||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||
- SwiftProtobuf (~> 1.5.0)
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
|
@ -145,6 +149,7 @@ SPEC REPOS:
|
|||
- AFNetworking
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- GoogleWebRTC
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
|
@ -152,6 +157,7 @@ SPEC REPOS:
|
|||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- ZXingObjC
|
||||
|
@ -206,6 +212,7 @@ SPEC CHECKSUMS:
|
|||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
GoogleWebRTC: b39a78c4f5cc6b0323415b9233db03a2faa7b0f0
|
||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
||||
Nimble: 0526ae760c851747ff4a682f7646af07a0cc2013
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
|
@ -216,6 +223,7 @@ SPEC CHECKSUMS:
|
|||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
|
@ -223,6 +231,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: cb9862059da2976422ff9c4fa94d406b68581456
|
||||
PODFILE CHECKSUM: 715ef2f16aa5c6957ff91d8dd7240a2cfba46aa2
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1210"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupSettingsViewController.h"
|
||||
#import "OWSBackup.h"
|
||||
#import "Session-Swift.h"
|
||||
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIColor+OWS.h>
|
||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||
#import <SessionUtilitiesKit/MIMETypeUtil.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupSettingsViewController ()
|
||||
|
||||
@property (nonatomic, nullable) NSError *iCloudError;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupSettingsViewController
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (OWSBackup *)backup
|
||||
{
|
||||
OWSAssertDebug(AppEnvironment.shared.backup);
|
||||
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
|
||||
self.title = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(backupStateDidChange:)
|
||||
name:NSNotificationNameBackupStateDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(applicationDidBecomeActive:)
|
||||
name:OWSApplicationDidBecomeActiveNotification
|
||||
object:nil];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
{
|
||||
[super viewDidAppear:animated];
|
||||
|
||||
[self updateTableContents];
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
- (void)updateICloudStatus
|
||||
{
|
||||
__weak OWSBackupSettingsViewController *weakSelf = self;
|
||||
[[self.backup ensureCloudKitAccess]
|
||||
.then(^{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = nil;
|
||||
[weakSelf updateTableContents];
|
||||
})
|
||||
.catch(^(NSError *error) {
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = error;
|
||||
[weakSelf updateTableContents];
|
||||
}) retainUntilComplete];
|
||||
}
|
||||
|
||||
#pragma mark - Table Contents
|
||||
|
||||
- (void)updateTableContents
|
||||
{
|
||||
OWSTableContents *contents = [OWSTableContents new];
|
||||
|
||||
BOOL isBackupEnabled = [OWSBackup.sharedManager isBackupEnabled];
|
||||
|
||||
if (self.iCloudError) {
|
||||
OWSTableSection *iCloudSection = [OWSTableSection new];
|
||||
iCloudSection.headerTitle = NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_ICLOUD_STATUS", @"Label for iCloud status row in the in the backup settings view.");
|
||||
[iCloudSection
|
||||
addItem:[OWSTableItem
|
||||
longDisclosureItemWithText:[OWSBackupAPI errorMessageForCloudKitAccessError:self.iCloudError]
|
||||
actionBlock:^{
|
||||
[[UIApplication sharedApplication]
|
||||
openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
|
||||
}]];
|
||||
[contents addSection:iCloudSection];
|
||||
}
|
||||
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *enableSection = [OWSTableSection new];
|
||||
enableSection.headerTitle = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
[enableSection
|
||||
addItem:[OWSTableItem switchItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_ENABLING_SWITCH",
|
||||
@"Label for switch in settings that controls whether or not backup is enabled.")
|
||||
isOnBlock:^{
|
||||
return [OWSBackup.sharedManager isBackupEnabled];
|
||||
}
|
||||
target:self
|
||||
selector:@selector(isBackupEnabledDidChange:)]];
|
||||
[contents addSection:enableSection];
|
||||
|
||||
if (isBackupEnabled) {
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *progressSection = [OWSTableSection new];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_STATUS",
|
||||
@"Label for backup status row in the in the backup settings view.")
|
||||
accessoryText:NSStringForBackupExportState(OWSBackup.sharedManager.backupExportState)]];
|
||||
if (OWSBackup.sharedManager.backupExportState == OWSBackupState_InProgress) {
|
||||
if (OWSBackup.sharedManager.backupExportDescription) {
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PHASE",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:OWSBackup.sharedManager.backupExportDescription]];
|
||||
if (OWSBackup.sharedManager.backupExportProgress) {
|
||||
NSUInteger progressPercent
|
||||
= (NSUInteger)round(OWSBackup.sharedManager.backupExportProgress.floatValue * 100);
|
||||
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
|
||||
[numberFormatter setNumberStyle:NSNumberFormatterPercentStyle];
|
||||
[numberFormatter setMaximumFractionDigits:0];
|
||||
[numberFormatter setMultiplier:@1];
|
||||
NSString *progressString = [numberFormatter stringFromNumber:@(progressPercent)];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PROGRESS",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:progressString]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (OWSBackup.sharedManager.backupExportState) {
|
||||
case OWSBackupState_Idle:
|
||||
case OWSBackupState_Failed:
|
||||
case OWSBackupState_Succeeded:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_BACKUP_NOW",
|
||||
@"Label for 'backup now' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager tryToExportBackup];
|
||||
}]];
|
||||
break;
|
||||
case OWSBackupState_InProgress:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_CANCEL_BACKUP",
|
||||
@"Label for 'cancel backup' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager cancelExportBackup];
|
||||
}]];
|
||||
break;
|
||||
}
|
||||
|
||||
[contents addSection:progressSection];
|
||||
}
|
||||
|
||||
self.contents = contents;
|
||||
}
|
||||
|
||||
- (void)isBackupEnabledDidChange:(UISwitch *)sender
|
||||
{
|
||||
[OWSBackup.sharedManager setIsBackupEnabled:sender.isOn];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
#pragma mark - Events
|
||||
|
||||
- (void)backupStateDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,357 @@
|
|||
import Foundation
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
import PromiseKit
|
||||
import CallKit
|
||||
|
||||
public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||
|
||||
@objc static let isEnabled = true
|
||||
|
||||
// MARK: Metadata Properties
|
||||
let uuid: String
|
||||
let callID: UUID // This is for CallKit
|
||||
let sessionID: String
|
||||
let mode: Mode
|
||||
var audioMode: AudioMode
|
||||
let webRTCSession: WebRTCSession
|
||||
let isOutgoing: Bool
|
||||
var remoteSDP: RTCSessionDescription? = nil
|
||||
var callMessageID: String?
|
||||
var answerCallAction: CXAnswerCallAction? = nil
|
||||
var contactName: String {
|
||||
let contact = Storage.shared.getContact(with: self.sessionID)
|
||||
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
|
||||
}
|
||||
var profilePicture: UIImage {
|
||||
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
|
||||
return result
|
||||
} else {
|
||||
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Control
|
||||
lazy public var videoCapturer: RTCVideoCapturer = {
|
||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||
}()
|
||||
|
||||
var isRemoteVideoEnabled = false {
|
||||
didSet {
|
||||
remoteVideoStateDidChange?(isRemoteVideoEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
var isMuted = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.mute()
|
||||
} else {
|
||||
webRTCSession.unmute()
|
||||
}
|
||||
}
|
||||
}
|
||||
var isVideoEnabled = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case offer
|
||||
case answer
|
||||
}
|
||||
|
||||
// MARK: End call mode
|
||||
enum EndCallMode {
|
||||
case local
|
||||
case remote
|
||||
case unanswered
|
||||
case answeredElsewhere
|
||||
}
|
||||
|
||||
// MARK: Audio I/O mode
|
||||
enum AudioMode {
|
||||
case earpiece
|
||||
case speaker
|
||||
case headphone
|
||||
case bluetooth
|
||||
}
|
||||
|
||||
// MARK: Call State Properties
|
||||
var connectingDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasStartedConnectingDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var connectedDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasConnectedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var endDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasEndedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet implemented
|
||||
var isOnHold = false {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State Change Callbacks
|
||||
var stateDidChange: (() -> Void)?
|
||||
var hasStartedConnectingDidChange: (() -> Void)?
|
||||
var hasConnectedDidChange: (() -> Void)?
|
||||
var hasEndedDidChange: (() -> Void)?
|
||||
var remoteVideoStateDidChange: ((Bool) -> Void)?
|
||||
var hasStartedReconnecting: (() -> Void)?
|
||||
var hasReconnected: (() -> Void)?
|
||||
|
||||
// MARK: Derived Properties
|
||||
var hasStartedConnecting: Bool {
|
||||
get { return connectingDate != nil }
|
||||
set { connectingDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasConnected: Bool {
|
||||
get { return connectedDate != nil }
|
||||
set { connectedDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasEnded: Bool {
|
||||
get { return endDate != nil }
|
||||
set { endDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var timeOutTimer: Timer? = nil
|
||||
var didTimeout = false
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let connectedDate = connectedDate else {
|
||||
return 0
|
||||
}
|
||||
if let endDate = endDate {
|
||||
return endDate.timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
return Date().timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
var reconnectTimer: Timer? = nil
|
||||
|
||||
// MARK: Initialization
|
||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
||||
self.sessionID = sessionID
|
||||
self.uuid = uuid
|
||||
self.callID = UUID()
|
||||
self.mode = mode
|
||||
self.audioMode = .earpiece
|
||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
|
||||
self.isOutgoing = outgoing
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
super.init()
|
||||
self.webRTCSession.delegate = self
|
||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||
AppEnvironment.shared.callManager.currentCall = self
|
||||
} else {
|
||||
SNLog("[Calls] A call is ongoing.")
|
||||
}
|
||||
}
|
||||
|
||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||
guard case .answer = mode else { return }
|
||||
setupTimeoutTimer()
|
||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||
SNLog("[Calls] Did receive remote sdp.")
|
||||
remoteSDP = sdp
|
||||
if hasStartedConnecting {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
func startSessionCall() {
|
||||
guard case .offer = mode else { return }
|
||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
||||
|
||||
let message = CallMessage()
|
||||
message.sender = getUserHexEncodedPublicKey()
|
||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
||||
message.uuid = self.uuid
|
||||
message.kind = .preOffer
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save()
|
||||
self.callMessageID = infoMessage.uniqueId
|
||||
|
||||
var promise: Promise<Void>!
|
||||
Storage.write(with: { transaction in
|
||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
let _ = promise.done {
|
||||
Storage.shared.write { transaction in
|
||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
||||
}
|
||||
self?.setupTimeoutTimer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
guard case .answer = mode else { return }
|
||||
hasStartedConnecting = true
|
||||
if let sdp = remoteSDP {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
func answerSessionCallInBackground(action: CXAnswerCallAction) {
|
||||
answerCallAction = action
|
||||
self.answerSessionCall()
|
||||
}
|
||||
|
||||
func endSessionCall() {
|
||||
guard !hasEnded else { return }
|
||||
webRTCSession.hangUp()
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
|
||||
}
|
||||
hasEnded = true
|
||||
}
|
||||
|
||||
// MARK: Update call message
|
||||
func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callMessageID = callMessageID else { return }
|
||||
Storage.write { transaction in
|
||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
||||
if let messageToUpdate = infoMessage {
|
||||
var shouldMarkAsRead = false
|
||||
if self.duration > 0 {
|
||||
shouldMarkAsRead = true
|
||||
} else if self.hasStartedConnecting {
|
||||
shouldMarkAsRead = true
|
||||
} else {
|
||||
switch mode {
|
||||
case .local:
|
||||
shouldMarkAsRead = true
|
||||
fallthrough
|
||||
case .remote:
|
||||
fallthrough
|
||||
case .unanswered:
|
||||
if messageToUpdate.callState == .incoming {
|
||||
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
case .answeredElsewhere:
|
||||
shouldMarkAsRead = true
|
||||
}
|
||||
}
|
||||
if shouldMarkAsRead {
|
||||
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Renderer
|
||||
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.removeRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachLocalRenderer(renderer)
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
public func webRTCIsConnected() {
|
||||
self.invalidateTimeoutTimer()
|
||||
self.reconnectTimer?.invalidate()
|
||||
guard !self.hasConnected else {
|
||||
hasReconnected?()
|
||||
return
|
||||
}
|
||||
self.hasConnected = true
|
||||
self.answerCallAction?.fulfill()
|
||||
}
|
||||
|
||||
public func isRemoteVideoDidChange(isEnabled: Bool) {
|
||||
isRemoteVideoEnabled = isEnabled
|
||||
}
|
||||
|
||||
public func didReceiveHangUpSignal() {
|
||||
self.hasEnded = true
|
||||
DispatchQueue.main.async {
|
||||
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
|
||||
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
public func dataChannelDidOpen() {
|
||||
// Send initial video status
|
||||
if (isVideoEnabled) {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnectIfNeeded() {
|
||||
setupTimeoutTimer()
|
||||
hasStartedReconnecting?()
|
||||
guard isOutgoing else { return }
|
||||
tryToReconnect()
|
||||
}
|
||||
|
||||
private func tryToReconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
||||
}
|
||||
} else {
|
||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||
self.tryToReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Timeout
|
||||
public func setupTimeoutTimer() {
|
||||
invalidateTimeoutTimer()
|
||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||
self.didTimeout = true
|
||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||
self.timeOutTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateTimeoutTimer() {
|
||||
timeOutTimer?.invalidate()
|
||||
timeOutTimer = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
public func startCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.startSessionCall()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func answerCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
||||
call.answerSessionCall()
|
||||
} else {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.currentCall!)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
call.answerSessionCall()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func endCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.endSessionCall()
|
||||
if call.didTimeout {
|
||||
reportCurrentCallEnded(reason: .unanswered)
|
||||
} else {
|
||||
reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.isMuted = isMuted
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import CallKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager {
|
||||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
guard case .offer = call.mode else { return }
|
||||
guard !call.hasConnected else { return }
|
||||
reportOutgoingCall(call)
|
||||
if callController != nil {
|
||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
||||
|
||||
startCallAction.isVideo = false
|
||||
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(startCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
startCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(answerCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
answerCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let endCallAction = CXEndCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(endCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
endCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Not currently in use
|
||||
public func setOnHoldStatus(for call: SessionCall) {
|
||||
if callController != nil {
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(setHeldCallAction)
|
||||
|
||||
requestTransaction(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestTransaction(_ transaction: CXTransaction, completion: ((Error?) -> Void)? = nil) {
|
||||
callController?.request(transaction) { error in
|
||||
if let error = error {
|
||||
SNLog("Error requesting transaction: \(error)")
|
||||
} else {
|
||||
SNLog("Requested transaction successfully")
|
||||
}
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import CallKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
AssertIsOnMainThread()
|
||||
currentCall?.endSessionCall()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
if startCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
print("[CallKit] Perform CXAnswerCallAction")
|
||||
guard let call = self.currentCall else { return action.fail() }
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
if answerCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
} else {
|
||||
call.answerSessionCallInBackground(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
print("[CallKit] Perform CXEndCallAction")
|
||||
AssertIsOnMainThread()
|
||||
if endCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||
AssertIsOnMainThread()
|
||||
if setMutedCallAction(isMuted: action.isMuted) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
|
||||
// TODO: set on hold
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
||||
// TODO: handle timeout
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did activate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did deactivate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import CallKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCallManager: NSObject {
|
||||
let provider: CXProvider?
|
||||
let callController: CXCallController?
|
||||
var currentCall: SessionCall? = nil {
|
||||
willSet {
|
||||
if (newValue != nil) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var _sharedProvider: CXProvider?
|
||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||
|
||||
if let sharedProvider = self._sharedProvider {
|
||||
sharedProvider.configuration = configuration
|
||||
return sharedProvider
|
||||
} else {
|
||||
SwiftSingletons.register(self)
|
||||
let provider = CXProvider(configuration: configuration)
|
||||
_sharedProvider = provider
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
||||
providerConfiguration.supportsVideo = true
|
||||
providerConfiguration.maximumCallGroups = 1
|
||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||
providerConfiguration.supportedHandleTypes = [.generic]
|
||||
let iconMaskImage = #imageLiteral(resourceName: "SessionGreen32")
|
||||
providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()
|
||||
providerConfiguration.includesCallsInRecents = useSystemCallLog
|
||||
|
||||
return providerConfiguration
|
||||
}
|
||||
|
||||
init(useSystemCallLog: Bool = false) {
|
||||
AssertIsOnMainThread()
|
||||
if SSKPreferences.isCallKitSupported {
|
||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
self.callController = CXCallController()
|
||||
} else {
|
||||
self.provider = nil
|
||||
self.callController = nil
|
||||
}
|
||||
super.init()
|
||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||
self.provider?.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: Report calls
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
||||
}
|
||||
if call.hasConnected {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reportIncomingCall(_ call: SessionCall, callerName: String, completion: @escaping (Error?) -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if let provider = provider {
|
||||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = callerName
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
||||
update.hasVideo = false
|
||||
|
||||
disableUnsupportedFeatures(callUpdate: update)
|
||||
|
||||
// Report the incoming call to the system
|
||||
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
|
||||
guard error == nil else {
|
||||
self.reportCurrentCallEnded(reason: .failed)
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
} else {
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||
guard let call = currentCall else { return }
|
||||
if let reason = reason {
|
||||
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
|
||||
switch (reason) {
|
||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||
default: call.updateCallMessage(mode: .remote)
|
||||
}
|
||||
} else {
|
||||
call.updateCallMessage(mode: .local)
|
||||
}
|
||||
call.webRTCSession.dropConnection()
|
||||
self.currentCall = nil
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
|
||||
}
|
||||
|
||||
// MARK: Util
|
||||
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||
// until user returns to in-app call screen.
|
||||
callUpdate.supportsHolding = false
|
||||
|
||||
// Not yet supported
|
||||
callUpdate.supportsGrouping = false
|
||||
callUpdate.supportsUngrouping = false
|
||||
|
||||
// Is there any reason to support this?
|
||||
callUpdate.supportsDTMF = false
|
||||
}
|
||||
|
||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
||||
let message = CallMessage()
|
||||
message.uuid = offerMessage.uuid
|
||||
message.kind = .endCall
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import WebRTC
|
||||
|
||||
extension CallVC : CameraManagerDelegate {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
|
||||
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
||||
let timestampNs = Int64(timestamp * 1000000000)
|
||||
let rotation: RTCVideoRotation = {
|
||||
switch UIDevice.current.orientation {
|
||||
case .landscapeRight: return RTCVideoRotation._90
|
||||
case .portraitUpsideDown: return RTCVideoRotation._180
|
||||
case .landscapeLeft: return RTCVideoRotation._270
|
||||
default: return RTCVideoRotation._0
|
||||
}
|
||||
}()
|
||||
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs)
|
||||
frame.timeStamp = Int32(timestamp)
|
||||
call.webRTCSession.handleLocalFrameCaptured(frame)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,550 @@
|
|||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
var durationTimer: Timer?
|
||||
var duration: Int = 0
|
||||
var shouldRestartCamera = true
|
||||
weak var conversationVC: ConversationVC? = nil
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var localVideoView: LocalVideoView = {
|
||||
let result = LocalVideoView()
|
||||
result.isHidden = !call.isVideoEnabled
|
||||
result.layer.cornerRadius = 10
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.width, to: LocalVideoView.width)
|
||||
result.set(.height, to: LocalVideoView.height)
|
||||
result.makeViewDraggable()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var remoteVideoView: RemoteVideoView = {
|
||||
let result = RemoteVideoView()
|
||||
result.alpha = 0
|
||||
result.backgroundColor = .black
|
||||
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped)))
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profilePictureView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
|
||||
result.image = self.call.profilePicture
|
||||
result.set(.width, to: radius * 2)
|
||||
result.set(.height, to: radius * 2)
|
||||
result.layer.cornerRadius = radius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var minimizeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = !call.hasConnected
|
||||
let image = UIImage(named: "Minimize")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = call.hasStartedConnecting
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var responsePanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing * 2 + 40
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchCameraButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isEnabled = call.isVideoEnabled
|
||||
let image = UIImage(named: "SwitchCamera")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchAudioButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AudioOff")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = call.isMuted ? Colors.destructive : UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var videoButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "VideoCall")?.withRenderingMode(.alwaysTemplate)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var volumeView: MPVolumeView = {
|
||||
let result = MPVolumeView()
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
result.showsVolumeSlider = false
|
||||
result.showsRouteButton = true
|
||||
result.setRouteButtonImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var operationPanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callInfoLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = call.hasConnected
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
if call.hasStartedConnecting { result.text = "Connecting..." }
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callDurationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = true
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
setupStateChangeCallbacks()
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
func setupStateChangeCallbacks() {
|
||||
self.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
}
|
||||
if self.callInfoLabel.alpha < 0.5 {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.call.hasStartedConnectingDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.text = "Connecting..."
|
||||
self.answerButton.alpha = 0
|
||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseIn, animations: {
|
||||
self.answerButton.isHidden = true
|
||||
}, completion: nil)
|
||||
}
|
||||
}
|
||||
self.call.hasConnectedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
self.callInfoLabel.text = "Connected"
|
||||
self.minimizeButton.isHidden = false
|
||||
self.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.updateDuration()
|
||||
}
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
self.call.hasEndedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.durationTimer?.invalidate()
|
||||
self.durationTimer = nil
|
||||
self.handleEndCallMessage()
|
||||
}
|
||||
}
|
||||
self.call.hasStartedReconnecting = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
self.callInfoLabel.text = "Reconnecting..."
|
||||
}
|
||||
}
|
||||
self.call.hasReconnected = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
if shouldRestartCamera { cameraManager.prepare() }
|
||||
touch(call.videoCapturer)
|
||||
titleLabel.text = self.call.contactName
|
||||
AppEnvironment.shared.callManager.startCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't start a call."
|
||||
self.endCall()
|
||||
} else {
|
||||
self.callInfoLabel.text = "Ringing..."
|
||||
self.answerButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
setupOrientationMonitoring()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Profile picture container
|
||||
let profilePictureContainer = UIView()
|
||||
view.addSubview(profilePictureContainer)
|
||||
// Remote video view
|
||||
call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
view.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: view)
|
||||
// Local video view
|
||||
call.attachLocalVideoRenderer(localVideoView)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Minimize button
|
||||
view.addSubview(minimizeButton)
|
||||
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
minimizeButton.pin(.left, to: .left, of: view)
|
||||
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: minimizeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
// Response Panel
|
||||
view.addSubview(responsePanel)
|
||||
responsePanel.center(.horizontal, in: view)
|
||||
responsePanel.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset)
|
||||
// Operation Panel
|
||||
view.addSubview(operationPanel)
|
||||
operationPanel.center(.horizontal, in: view)
|
||||
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
|
||||
// Profile picture view
|
||||
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
|
||||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
profilePictureContainer.addSubview(profilePictureView)
|
||||
profilePictureView.center(in: profilePictureContainer)
|
||||
// Call info label
|
||||
let callInfoLabelContainer = UIView()
|
||||
view.addSubview(callInfoLabelContainer)
|
||||
callInfoLabelContainer.pin(.top, to: .bottom, of: profilePictureView)
|
||||
callInfoLabelContainer.pin(.bottom, to: .bottom, of: profilePictureContainer)
|
||||
callInfoLabelContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
callInfoLabelContainer.addSubview(callInfoLabel)
|
||||
callInfoLabelContainer.addSubview(callDurationLabel)
|
||||
callInfoLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callInfoLabel.center(in: callInfoLabelContainer)
|
||||
callDurationLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callDurationLabel.center(in: callInfoLabelContainer)
|
||||
}
|
||||
|
||||
private func addLocalVideoView() {
|
||||
let safeAreaInsets = UIApplication.shared.keyWindow!.safeAreaInsets
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(localVideoView)
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
|
||||
let topMargin = safeAreaInsets.top + Values.veryLargeSpacing
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
|
||||
shouldRestartCamera = true
|
||||
addLocalVideoView()
|
||||
remoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
|
||||
localVideoView.removeFromSuperview()
|
||||
}
|
||||
|
||||
// MARK: - Orientation
|
||||
|
||||
private func setupOrientationMonitoring() {
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
|
||||
}
|
||||
|
||||
@objc func didChangeDeviceOrientation(notification: Notification) {
|
||||
|
||||
func rotateAllButtons(rotationAngle: CGFloat) {
|
||||
let transform = CGAffineTransform(rotationAngle: rotationAngle)
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.answerButton.transform = transform
|
||||
self.hangUpButton.transform = transform
|
||||
self.switchAudioButton.transform = transform
|
||||
self.switchCameraButton.transform = transform
|
||||
self.videoButton.transform = transform
|
||||
self.volumeView.transform = transform
|
||||
}
|
||||
}
|
||||
|
||||
switch UIDevice.current.orientation {
|
||||
case .portrait:
|
||||
rotateAllButtons(rotationAngle: 0)
|
||||
case .portraitUpsideDown:
|
||||
rotateAllButtons(rotationAngle: .pi)
|
||||
case .landscapeLeft:
|
||||
rotateAllButtons(rotationAngle: .halfPi)
|
||||
case .landscapeRight:
|
||||
rotateAllButtons(rotationAngle: .pi + .halfPi)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Call signalling
|
||||
func handleAnswerMessage(_ message: CallMessage) {
|
||||
callInfoLabel.text = "Connecting..."
|
||||
}
|
||||
|
||||
func handleEndCallMessage() {
|
||||
SNLog("[Calls] Ending call.")
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
callInfoLabel.text = "Call Ended"
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = 0
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
AppEnvironment.shared.callManager.answerCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't answer the call."
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateDuration() {
|
||||
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
|
||||
duration += 1
|
||||
}
|
||||
|
||||
// MARK: Minimize to a floating view
|
||||
@objc private func minimize() {
|
||||
self.shouldRestartCamera = false
|
||||
let miniCallView = MiniCallView(from: self)
|
||||
miniCallView.show()
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Video and Audio
|
||||
@objc private func operateCamera() {
|
||||
if (call.isVideoEnabled) {
|
||||
localVideoView.isHidden = true
|
||||
cameraManager.stop()
|
||||
videoButton.tintColor = .white
|
||||
videoButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
switchCameraButton.isEnabled = false
|
||||
call.isVideoEnabled = false
|
||||
} else {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
let previewVC = VideoPreviewVC()
|
||||
previewVC.delegate = self
|
||||
present(previewVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDidConfirmTurningOn() {
|
||||
localVideoView.isHidden = false
|
||||
cameraManager.prepare()
|
||||
cameraManager.start()
|
||||
videoButton.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
videoButton.backgroundColor = .white
|
||||
switchCameraButton.isEnabled = true
|
||||
call.isVideoEnabled = true
|
||||
}
|
||||
|
||||
@objc private func switchCamera() {
|
||||
cameraManager.switchCamera()
|
||||
}
|
||||
|
||||
@objc private func switchAudio() {
|
||||
if call.isMuted {
|
||||
switchAudioButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
call.isMuted = false
|
||||
} else {
|
||||
switchAudioButton.backgroundColor = Colors.destructive
|
||||
call.isMuted = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func audioRouteDidChange() {
|
||||
let currentSession = AVAudioSession.sharedInstance()
|
||||
let currentRoute = currentSession.currentRoute
|
||||
if let currentOutput = currentRoute.outputs.first {
|
||||
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
|
||||
latestKnownAudioOutputDeviceName = currentOutput.portName
|
||||
switch currentOutput.portType {
|
||||
case .builtInSpeaker:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .headphones:
|
||||
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothLE: fallthrough
|
||||
case .bluetoothA2DP:
|
||||
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothHFP:
|
||||
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .builtInReceiver: fallthrough
|
||||
default:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = .white
|
||||
volumeView.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) {
|
||||
let isHidden = callDurationLabel.alpha < 0.5
|
||||
UIView.animate(withDuration: 0.5) {
|
||||
self.operationPanel.alpha = isHidden ? 1 : 0
|
||||
self.responsePanel.alpha = isHidden ? 1 : 0
|
||||
self.callDurationLabel.alpha = isHidden ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc
|
||||
protocol CameraManagerDelegate : AnyObject {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer)
|
||||
}
|
||||
|
||||
final class CameraManager : NSObject {
|
||||
private let captureSession = AVCaptureSession()
|
||||
private let videoDataOutput = AVCaptureVideoDataOutput()
|
||||
private let videoDataOutputQueue
|
||||
= DispatchQueue(label: "CameraManager.videoDataOutputQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
|
||||
private let audioDataOutput = AVCaptureAudioDataOutput()
|
||||
private var isCapturing = false
|
||||
weak var delegate: CameraManagerDelegate?
|
||||
|
||||
private var videoCaptureDevice: AVCaptureDevice?
|
||||
private var videoInput: AVCaptureDeviceInput?
|
||||
|
||||
func prepare() {
|
||||
print("[Calls] Preparing camera.")
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
|
||||
private func addNewVideoIO(position: AVCaptureDevice.Position) {
|
||||
if let videoCaptureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
|
||||
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), captureSession.canAddInput(videoInput) {
|
||||
captureSession.addInput(videoInput)
|
||||
self.videoCaptureDevice = videoCaptureDevice
|
||||
self.videoInput = videoInput
|
||||
}
|
||||
if captureSession.canAddOutput(videoDataOutput) {
|
||||
captureSession.addOutput(videoDataOutput)
|
||||
videoDataOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String : Int(kCVPixelFormatType_32BGRA) ]
|
||||
videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
|
||||
guard let connection = videoDataOutput.connection(with: AVMediaType.video) else { return }
|
||||
connection.videoOrientation = .portrait
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = (position == .front)
|
||||
} else {
|
||||
SNLog("Couldn't add video data output to capture session.")
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isCapturing else { return }
|
||||
print("[Calls] Starting camera.")
|
||||
isCapturing = true
|
||||
captureSession.startRunning()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isCapturing else { return }
|
||||
print("[Calls] Stopping camera.")
|
||||
isCapturing = false
|
||||
captureSession.stopRunning()
|
||||
}
|
||||
|
||||
func switchCamera() {
|
||||
guard let videoCaptureDevice = videoCaptureDevice, let videoInput = videoInput else { return }
|
||||
stop()
|
||||
if videoCaptureDevice.position == .front {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .back)
|
||||
} else {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
extension CameraManager : AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
guard connection == videoDataOutput.connection(with: .video) else { return }
|
||||
delegate?.handleVideoOutputCaptured(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
print("[Calls] Frame dropped.")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
|
||||
public protocol VideoPreviewDelegate : AnyObject {
|
||||
func cameraDidConfirmTurningOn()
|
||||
}
|
||||
|
||||
class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
||||
weak var delegate: VideoPreviewDelegate?
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var renderView: RenderView = {
|
||||
let result = RenderView()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var closeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "X")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "Check")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Preview"
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
cameraManager.prepare()
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Preview video view
|
||||
view.addSubview(renderView)
|
||||
renderView.translatesAutoresizingMaskIntoConstraints = false
|
||||
renderView.pin(to: view)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Close button
|
||||
view.addSubview(closeButton)
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButton.pin(.left, to: .left, of: view)
|
||||
closeButton.center(.vertical, in: fadeView)
|
||||
// Confirm button
|
||||
view.addSubview(confirmButton)
|
||||
confirmButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
confirmButton.pin(.right, to: .right, of: view)
|
||||
confirmButton.center(.vertical, in: fadeView)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: closeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
cameraManager.start()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cameraManager.stop()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func confirm() {
|
||||
delegate?.cameraDidConfirmTurningOn()
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func cancel() {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: CameraManagerDelegate
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
renderView.enqueue(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import UIKit
|
||||
|
||||
@objc
|
||||
final class CallMissedTipsModal : Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Tips icon
|
||||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
tipsIconImageView.set(.height, to: 28)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
// Cancel Button
|
||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
import WebRTC
|
||||
import Foundation
|
||||
|
||||
#if arch(arm64)
|
||||
// Note: 'RTCMTLVideoView' requires arm64 (so won't work on the simulator which
|
||||
// we need to build for x86_64 due to WebRTC not supporting arm64 simulator builds)
|
||||
typealias TargetView = RTCMTLVideoView
|
||||
#else
|
||||
typealias TargetView = RTCEAGLVideoView
|
||||
#endif
|
||||
|
||||
// MARK: RemoteVideoView
|
||||
|
||||
class RemoteVideoView: TargetView {
|
||||
|
||||
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 arch(arm64)
|
||||
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
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LocalVideoView
|
||||
|
||||
class LocalVideoView: TargetView {
|
||||
|
||||
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)
|
||||
#if arch(arm64)
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
private static let swipeToOperateThreshold: CGFloat = 60
|
||||
private var previousY: CGFloat = 0
|
||||
let call: SessionCall
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result = ProfilePictureView()
|
||||
let size = CGFloat(60)
|
||||
result.size = size
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = UIColor.white
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 24.8, height: 24.8))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 29.6, height: 11.2))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: IncomingCallBanner?
|
||||
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
if let incomingCallBanner = IncomingCallBanner.current {
|
||||
incomingCallBanner.dismiss()
|
||||
}
|
||||
IncomingCallBanner.current = self
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(0.8)
|
||||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
self.set(.height, to: 100)
|
||||
profilePictureView.publicKey = call.sessionID
|
||||
profilePictureView.update()
|
||||
displayNameLabel.text = call.contactName
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = Values.largeSpacing
|
||||
self.addSubview(stackView)
|
||||
stackView.center(.vertical, in: self)
|
||||
stackView.autoPinWidthToSuperview(withMargin: Values.mediumSpacing)
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == panGestureRecognizer {
|
||||
let v = panGestureRecognizer.velocity(in: self)
|
||||
return abs(v.y) > abs(v.x) // It has to be more vertical than horizontal
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
showCallVC(answer: false)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
let translationY = gestureRecognizer.translation(in: self).y
|
||||
switch gestureRecognizer.state {
|
||||
case .changed:
|
||||
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
|
||||
}
|
||||
previousY = translationY
|
||||
case .ended, .cancelled:
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
|
||||
if translationY > 0 { showCallVC(answer: false) }
|
||||
else { endCall() } // TODO: Or just put the call on hold?
|
||||
} else {
|
||||
self.transform = .identity
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
showCallVC(answer: true)
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
public func showCallVC(answer: Bool) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.call)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
if answer { self.call.answerSessionCall() }
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
let topMargin = window.safeAreaInsets.top - Values.smallSpacing
|
||||
self.autoPinWidthToSuperview(withMargin: Values.smallSpacing)
|
||||
self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
CallRingTonePlayer.shared.startVibration()
|
||||
CallRingTonePlayer.shared.startPlayingRingTone()
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
CallRingTonePlayer.shared.stopVibrationIfPossible()
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
IncomingCallBanner.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
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?
|
||||
|
||||
#if arch(arm64)
|
||||
// Note: 'RTCMTLVideoView' requires arm64 (so won't work on the simulator which
|
||||
// we need to build for x86_64 due to WebRTC not supporting arm64 simulator builds)
|
||||
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
|
||||
}()
|
||||
#else
|
||||
private lazy var remoteVideoView: RTCEAGLVideoView = {
|
||||
let result = RTCEAGLVideoView()
|
||||
result.delegate = self
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.backgroundColor = .black
|
||||
return result
|
||||
}()
|
||||
#endif
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: MiniCallView?
|
||||
|
||||
init(from callVC: CallVC) {
|
||||
self.callVC = callVC
|
||||
super.init(frame: CGRect.zero)
|
||||
self.backgroundColor = UIColor.init(white: 0, alpha: 0.8)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
MiniCallView.current = self
|
||||
self.callVC.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
if !isEnabled {
|
||||
self.width?.constant = MiniCallView.defaultSize
|
||||
self.height?.constant = MiniCallView.defaultSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.width = self.set(.width, to: MiniCallView.defaultSize)
|
||||
self.height = self.set(.height, to: MiniCallView.defaultSize)
|
||||
self.layer.cornerRadius = 10
|
||||
self.layer.masksToBounds = true
|
||||
// Background
|
||||
let background = getBackgroudView()
|
||||
self.addSubview(background)
|
||||
background.pin(to: self)
|
||||
// Remote video view
|
||||
callVC.call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
self.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: self)
|
||||
}
|
||||
|
||||
private func getBackgroudView() -> UIView {
|
||||
let background = UIView()
|
||||
let imageView = UIImageView()
|
||||
imageView.layer.cornerRadius = 32
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.image = callVC.call.profilePicture
|
||||
background.addSubview(imageView)
|
||||
imageView.set(.width, to: 64)
|
||||
imageView.set(.height, to: 64)
|
||||
imageView.center(in: background)
|
||||
return background
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
makeViewDraggable()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
left = self.autoPinEdge(toSuperviewEdge: .left)
|
||||
left?.isActive = false
|
||||
right = self.autoPinEdge(toSuperviewEdge: .right)
|
||||
top = self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
bottom = self.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin)
|
||||
bottom?.isActive = false
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
self.callVC.call.removeRemoteVideoRenderer(self.remoteVideoView)
|
||||
self.callVC.setupStateChangeCallbacks()
|
||||
MiniCallView.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: RTCVideoViewDelegate
|
||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||
let newSize = CGSize(width: min(160.0, 160.0 * size.width / size.height), height: min(160.0, 160.0 * size.height / size.width))
|
||||
persistCurrentPosition(newSize: newSize)
|
||||
self.width?.constant = newSize.width
|
||||
self.height?.constant = newSize.height
|
||||
}
|
||||
|
||||
func persistCurrentPosition(newSize: CGSize) {
|
||||
let currentCenter = self.center
|
||||
|
||||
if currentCenter.x < self.superview!.width() / 2 {
|
||||
left?.isActive = true
|
||||
right?.isActive = false
|
||||
} else {
|
||||
left?.isActive = false
|
||||
right?.isActive = true
|
||||
}
|
||||
|
||||
let willTouchTop = currentCenter.y < newSize.height / 2 + topMargin
|
||||
let willTouchBottom = currentCenter.y + newSize.height / 2 >= self.superview!.height()
|
||||
if willTouchBottom {
|
||||
top?.isActive = false
|
||||
bottom?.isActive = true
|
||||
} else {
|
||||
let constant = willTouchTop ? topMargin : currentCenter.y - newSize.height / 2
|
||||
top?.constant = constant
|
||||
top?.isActive = true
|
||||
bottom?.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import CoreMedia
|
||||
|
||||
class RenderView: UIView {
|
||||
|
||||
private lazy var displayLayer: AVSampleBufferDisplayLayer = {
|
||||
let result = AVSampleBufferDisplayLayer()
|
||||
result.videoGravity = .resizeAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
self.layer.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
displayLayer.frame = self.bounds
|
||||
}
|
||||
|
||||
public func enqueue(sampleBuffer: CMSampleBuffer) {
|
||||
displayLayer.enqueue(sampleBuffer)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -41,6 +41,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() {
|
||||
|
@ -181,7 +201,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 {
|
||||
|
@ -533,7 +553,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 {
|
||||
|
@ -592,6 +616,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:
|
||||
|
@ -653,7 +683,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}))
|
||||
}
|
||||
}
|
||||
present(sheet, animated: true, completion: nil)
|
||||
presentAlert(sheet)
|
||||
}
|
||||
|
||||
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) {
|
||||
|
@ -791,7 +821,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
present(alert, animated: true, completion: nil)
|
||||
presentAlert(alert)
|
||||
}
|
||||
|
||||
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) {
|
||||
|
@ -813,7 +843,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
present(alert, animated: true, completion: nil)
|
||||
presentAlert(alert)
|
||||
}
|
||||
|
||||
func handleQuoteViewCancelButtonTapped() {
|
||||
|
@ -1042,94 +1072,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
|
||||
|
|
|
@ -339,13 +339,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)
|
||||
|
@ -429,6 +424,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
highlightFocusedMessageIfNeeded()
|
||||
didFinishInitialLayout = true
|
||||
markAllAsRead()
|
||||
self.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
|
@ -442,7 +438,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) {
|
||||
|
@ -450,6 +446,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)
|
||||
}
|
||||
|
@ -478,38 +480,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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -923,7 +929,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
|
||||
|
@ -957,7 +988,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
|
|||
navBar.stubbedNextResponder = self
|
||||
}
|
||||
|
||||
func hideSearchUI() {
|
||||
@objc func hideSearchUI(_ sender: Any? = nil) {
|
||||
isShowingSearchUI = false
|
||||
navigationItem.titleView = titleView
|
||||
updateNavBarButtons()
|
||||
|
|
|
@ -131,7 +131,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
|
|||
|
||||
- (void)copyMediaAction;
|
||||
- (void)copyTextAction;
|
||||
- (void)shareMediaAction;
|
||||
- (void)saveMediaAction;
|
||||
|
||||
- (BOOL)canCopyMedia;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -165,6 +165,13 @@ public class LongTextViewController: OWSViewController {
|
|||
// MARK: - Actions
|
||||
|
||||
@objc func shareButtonPressed() {
|
||||
AttachmentSharing.showShareUI(forText: fullText)
|
||||
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
self.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageCell : MessageCell {
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||
|
||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
|
||||
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result = UIView()
|
||||
result.set(.height, to: 50)
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
label.autoCenterInSuperview()
|
||||
result.addSubview(iconImageView)
|
||||
iconImageView.autoVCenterInSuperview()
|
||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||
result.addSubview(infoImageView)
|
||||
infoImageView.autoVCenterInSuperview()
|
||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
|
||||
override class var identifier: String { "CallMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
container.autoPinWidthToSuperview()
|
||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
|
||||
}
|
||||
|
||||
override func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let icon: UIImage?
|
||||
switch message.callState {
|
||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
||||
default: icon = nil
|
||||
}
|
||||
iconImageView.image = icon
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
|
||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
|
||||
let date = message.dateForUI()
|
||||
let description = DateUtil.formatDate(forDisplay: date)
|
||||
timestampLabel.text = description
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
if shouldBeTappable {
|
||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
final class CallMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
||||
// Image view
|
||||
let iconSize = CallMessageView.iconSize
|
||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = message.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ final class LinkPreviewView : UIView {
|
|||
private lazy var sentLinkPreviewTextColor: UIColor = {
|
||||
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
|
||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||
case (true, .dark), (false, .light): return .black
|
||||
case (false, .light): return .black
|
||||
case (true, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
|
|
|
@ -57,7 +57,11 @@ class MessageCell : UITableViewCell {
|
|||
switch viewItem.interaction {
|
||||
case is TSIncomingMessage: fallthrough
|
||||
case is TSOutgoingMessage: return VisibleMessageCell.self
|
||||
case is TSInfoMessage: return InfoMessageCell.self
|
||||
case is TSInfoMessage:
|
||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
||||
return CallMessageCell.self
|
||||
}
|
||||
return InfoMessageCell.self
|
||||
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
|
||||
default: preconditionFailure()
|
||||
}
|
||||
|
|
|
@ -126,7 +126,13 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
static let largeCornerRadius: CGFloat = 18
|
||||
static let contactThreadHSpacing = Values.mediumSpacing
|
||||
|
||||
static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
|
||||
static var gutterSize: CGFloat = {
|
||||
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
|
||||
if UIDevice.current.isIPad {
|
||||
result += CGFloat(UIScreen.main.bounds.width / 2 - 88)
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
private var bodyLabelTextColor: UIColor {
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
|
@ -258,7 +264,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
messageStatusImageView.tintColor = tintColor
|
||||
messageStatusImageView.backgroundColor = backgroundColor
|
||||
if let message = message as? TSOutgoingMessage {
|
||||
messageStatusImageView.isHidden = (message.messageState == .sent && thread?.lastInteraction != message)
|
||||
messageStatusImageView.isHidden = (message.isCallMessage || message.messageState == .sent && thread?.lastInteraction != message)
|
||||
} else {
|
||||
messageStatusImageView.isHidden = true
|
||||
}
|
||||
|
@ -276,7 +282,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing)
|
||||
timerViewIncomingMessageConstraint.isActive = (direction == .incoming)
|
||||
// Swipe to reply
|
||||
if (message.isDeleted) {
|
||||
if (message.isDeleted || message.isCallMessage) {
|
||||
removeGestureRecognizer(panGestureRecognizer)
|
||||
} else {
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
|
@ -319,6 +325,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset
|
||||
if let linkPreview = viewItem.linkPreview {
|
||||
let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self)
|
||||
linkPreviewView.layer.mask = bubbleViewMaskLayer
|
||||
linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment)
|
||||
snContentView.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(to: snContentView)
|
||||
|
@ -326,6 +333,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
self.bodyTextView = linkPreviewView.bodyTextView
|
||||
} else if let openGroupInvitationName = message.openGroupInvitationName, let openGroupInvitationURL = message.openGroupInvitationURL {
|
||||
let openGroupInvitationView = OpenGroupInvitationView(name: openGroupInvitationName, url: openGroupInvitationURL, textColor: bodyLabelTextColor, isOutgoing: isOutgoing)
|
||||
openGroupInvitationView.layer.mask = bubbleViewMaskLayer
|
||||
snContentView.addSubview(openGroupInvitationView)
|
||||
openGroupInvitationView.pin(to: snContentView)
|
||||
openGroupInvitationView.layer.mask = bubbleViewMaskLayer
|
||||
|
|
|
@ -23,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
|
||||
|
@ -51,15 +51,20 @@ 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
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
@objc
|
||||
final class CallModal : Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areCallsEnabled = true
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallPermissionRequestModal : Modal {
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_permission_request_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_permission_request_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let goToSettingsButton = UIButton()
|
||||
goToSettingsButton.set(.height, to: Values.mediumButtonHeight)
|
||||
goToSettingsButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
goToSettingsButton.backgroundColor = Colors.buttonBackground
|
||||
goToSettingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
goToSettingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
goToSettingsButton.setTitle(NSLocalizedString("vc_settings_title", comment: ""), for: UIControl.State.normal)
|
||||
goToSettingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, goToSettingsButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func goToSettings(_ sender: Any) {
|
||||
dismiss(animated: true, completion: {
|
||||
if let vc = CurrentAppContext().frontmostViewController() {
|
||||
let privacySettingsVC = PrivacySettingsTableViewController()
|
||||
privacySettingsVC.shouldShowCloseButton = true
|
||||
let nav = OWSNavigationController(rootViewController: privacySettingsVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
vc.present(nav, animated: true, completion: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -23,6 +23,14 @@ final class ConversationTitleView : UIView {
|
|||
result.lineBreakMode = .byTruncatingTail
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(thread: TSThread) {
|
||||
|
@ -40,11 +48,6 @@ final class ConversationTitleView : UIView {
|
|||
}
|
||||
|
||||
private func initialize() {
|
||||
let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
|
@ -67,6 +70,11 @@ final class ConversationTitleView : UIView {
|
|||
subtitleLabel.attributedText = subtitle
|
||||
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
|
||||
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
|
||||
|
||||
// Update title left margin
|
||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread() && !thread.isMessageRequest()
|
||||
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
|
|
|
@ -23,7 +23,7 @@ final class DownloadAttachmentModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -41,7 +41,6 @@ final class DownloadAttachmentModal : Modal {
|
|||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
downloadButton.backgroundColor = Colors.buttonBackground
|
||||
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||
|
@ -51,15 +50,20 @@ final class DownloadAttachmentModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -24,7 +24,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
|
||||
|
@ -52,15 +52,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
|
||||
|
@ -68,7 +73,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(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)
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ final class LinkPreviewModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -46,15 +46,20 @@ final class LinkPreviewModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -14,6 +14,7 @@ final class MessagesTableView : UITableView {
|
|||
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
|
||||
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
|
||||
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
|
||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
||||
separatorStyle = .none
|
||||
backgroundColor = .clear
|
||||
showsVerticalScrollIndicator = false
|
||||
|
|
|
@ -22,7 +22,7 @@ final class PermissionMissingModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Session"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -50,15 +50,20 @@ final class PermissionMissingModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -5,7 +5,7 @@ final class SendSeedModal : Modal {
|
|||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = NSLocalizedString("modal_send_seed_title", comment: "")
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
|
@ -44,19 +44,27 @@ final class SendSeedModal : Modal {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, buttonStackView ])
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func populateContentView() {
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -20,7 +20,7 @@ final class URLModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -48,15 +48,20 @@ final class URLModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -73,7 +73,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
let tabBarInset: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
tabBarInset = navigationBar.height()
|
||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
||||
} else {
|
||||
tabBarInset = 0
|
||||
}
|
||||
|
@ -177,6 +177,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
weak var NewDMVC: NewDMVC!
|
||||
private var isKeyboardShowing = false
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0
|
||||
|
||||
// MARK: Components
|
||||
private lazy var publicKeyTextView: TextView = {
|
||||
|
@ -212,8 +213,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
private lazy var buttonContainer: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.mediumSpacing
|
||||
result.spacing = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing
|
||||
result.distribution = .fillEqually
|
||||
if (UIDevice.current.isIPad) {
|
||||
result.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin)
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -221,6 +226,8 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
override func viewDidLoad() {
|
||||
// Remove background color
|
||||
view.backgroundColor = .clear
|
||||
// User session id container
|
||||
let userPublicKeyContainer = UIView(wrapping: userPublicKeyLabel, withInsets: .zero, shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth)
|
||||
// Explanation label
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
|
@ -240,14 +247,9 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
let nextButton = Button(style: .prominentOutline, size: .large)
|
||||
nextButton.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal)
|
||||
nextButton.addTarget(self, action: #selector(startNewDMIfPossible), for: UIControl.Event.touchUpInside)
|
||||
let nextButtonContainer = UIView()
|
||||
nextButtonContainer.addSubview(nextButton)
|
||||
nextButton.pin(.leading, to: .leading, of: nextButtonContainer, withInset: 80)
|
||||
nextButton.pin(.top, to: .top, of: nextButtonContainer)
|
||||
nextButtonContainer.pin(.trailing, to: .trailing, of: nextButton, withInset: 80)
|
||||
nextButtonContainer.pin(.bottom, to: .bottom, of: nextButton)
|
||||
let nextButtonContainer = UIView(wrapping: nextButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyLabel, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyContainer, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing)
|
||||
|
@ -256,7 +258,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
mainStackView.pin(.leading, to: .leading, of: view)
|
||||
mainStackView.pin(.top, to: .top, of: view)
|
||||
view.pin(.trailing, to: .trailing, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView, withInset: bottomMargin)
|
||||
// Width constraint
|
||||
view.set(.width, to: UIScreen.main.bounds.width)
|
||||
// Dismiss keyboard on tap
|
||||
|
@ -297,7 +299,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
guard !isKeyboardShowing else { return }
|
||||
isKeyboardShowing = true
|
||||
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
|
||||
bottomConstraint.constant = newHeight
|
||||
bottomConstraint.constant = newHeight + bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 0
|
||||
|
@ -310,7 +312,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
@objc private func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
guard isKeyboardShowing else { return }
|
||||
isKeyboardShowing = false
|
||||
bottomConstraint.constant = 0
|
||||
bottomConstraint.constant = bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 1
|
||||
|
@ -332,6 +334,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
|
||||
@objc private func sharePublicKey() {
|
||||
let shareVC = UIActivityViewController(activityItems: [ getUserHexEncodedPublicKey() ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
NewDMVC.navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
|
||||
|
@ -94,8 +96,23 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
// On iPad, the cancel button won't show
|
||||
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
|
||||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadTableData() {
|
||||
|
@ -150,6 +167,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
||||
Storage.shared.clearRecentSearchResults()
|
||||
}
|
||||
|
||||
@objc func cancel(_ sender: Any) {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_home_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
createNewPrivateChatButton.set(.width, to: Values.iPadButtonWidth)
|
||||
let result = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.mediumSpacing
|
||||
|
@ -154,7 +154,6 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name(kNSNotificationName_LocalProfileDidChange), object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil)
|
||||
// Threads (part 2)
|
||||
threads = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup, TSInboxGroup ], view: TSThreadDatabaseViewExtensionName) // The extension should be registered at this point
|
||||
threads.setIsReversed(true, forGroup: TSInboxGroup)
|
||||
|
@ -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
|
||||
|
||||
|
@ -628,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)
|
||||
}
|
||||
|
||||
|
@ -641,12 +646,18 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
|
|||
func createNewDMFromDeepLink(sessionID: String) {
|
||||
let newDMVC = NewDMVC(sessionID: sessionID)
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func createClosedGroup() {
|
||||
let newClosedGroupVC = NewClosedGroupVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newClosedGroupVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -153,8 +153,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.largeSpacing
|
||||
),
|
||||
// Note: The '182' is to match the 'Next' button on the New DM page (which doesn't have a fixed width)
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: 182),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
|
||||
clearAllButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
|
||||
])
|
||||
}
|
||||
|
|
|
@ -379,7 +379,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||
collectionView.reloadData()
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
func clearCollectionViewSelection() {
|
||||
|
@ -551,12 +551,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
let isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
if isSelected {
|
||||
cell.isSelected = isSelected
|
||||
} else {
|
||||
cell.isSelected = isSelected
|
||||
}
|
||||
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
//
|
||||
|
||||
#import "MediaDetailViewController.h"
|
||||
#import "AttachmentSharing.h"
|
||||
#import "ConversationViewItem.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "TSAttachmentStream.h"
|
||||
|
|
|
@ -381,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()
|
||||
|
@ -390,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
|
||||
|
|
|
@ -114,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];
|
||||
}
|
||||
|
@ -175,7 +180,7 @@ static NSTimeInterval launchStartedAt;
|
|||
[SNConfiguration performMainSetup];
|
||||
|
||||
[SNAppearance switchToSessionAppearance];
|
||||
|
||||
|
||||
if (CurrentAppContext().isRunningTests) {
|
||||
return YES;
|
||||
}
|
||||
|
@ -190,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
|
||||
|
@ -212,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;
|
||||
}
|
||||
|
||||
|
@ -399,6 +403,7 @@ static NSTimeInterval launchStartedAt;
|
|||
if (CurrentAppContext().isMainApp) {
|
||||
[SNJobQueue.shared resumePendingJobs];
|
||||
[self syncConfigurationIfNeeded];
|
||||
[self handleAppActivatedWithOngoingCallIfNeeded];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,8 +1,155 @@
|
|||
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 }
|
||||
|
@ -21,6 +168,7 @@ extension AppDelegate {
|
|||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
// MARK: Closed group poller
|
||||
@objc func startClosedGroupPoller() {
|
||||
guard OWSIdentityManager.shared().identityKeyPair() != nil else { return }
|
||||
ClosedGroupPoller.shared.start()
|
||||
|
@ -29,4 +177,5 @@ extension AppDelegate {
|
|||
@objc func stopClosedGroupPoller() {
|
||||
ClosedGroupPoller.shared.stop()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -27,6 +27,9 @@ import SignalUtilitiesKit
|
|||
|
||||
@objc
|
||||
public var accountManager: AccountManager
|
||||
|
||||
@objc
|
||||
public var callManager: SessionCallManager
|
||||
|
||||
@objc
|
||||
public var notificationPresenter: NotificationPresenter
|
||||
|
@ -48,6 +51,7 @@ import SignalUtilitiesKit
|
|||
|
||||
private override init() {
|
||||
self.accountManager = AccountManager()
|
||||
self.callManager = SessionCallManager()
|
||||
self.notificationPresenter = NotificationPresenter()
|
||||
self.pushRegistrationManager = PushRegistrationManager()
|
||||
self._userNotificationActionHandler = UserNotificationActionHandler()
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Airpods.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AnswerCall.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "audio_off_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bluetooth.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallIncoming.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallMissed.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallOutgoing.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "check.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Path.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Headsets.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "minimize.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Phone.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "speaker.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "switch_camera_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/SwitchCamera.imageset/switch_camera_fill.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/SwitchCamera.imageset/switch_camera_fill.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Tips.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "video_call_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/VideoCall.imageset/video_call_fill.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/VideoCall.imageset/video_call_fill.pdf
vendored
Normal file
Binary file not shown.
|
@ -91,6 +91,8 @@
|
|||
<string>Signal uses your contacts to find users you know. We do not store your contacts on the server.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Session's Screen Lock feature uses Face ID.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>com.loki-project.loki-messenger</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Session needs access to your microphone to record media.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
|
@ -105,6 +107,11 @@
|
|||
<string>SpaceMono-Bold.ttf</string>
|
||||
<string>SpaceMono-Regular.ttf</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -118,8 +125,10 @@
|
|||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>Launch Screen</string>
|
||||
|
@ -127,6 +136,8 @@
|
|||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleLightContent</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
#import <SignalCoreKit/OWSAsserts.h>
|
||||
#import <SignalCoreKit/OWSLogs.h>
|
||||
#import <SignalCoreKit/Threading.h>
|
||||
#import <SignalUtilitiesKit/AttachmentSharing.h>
|
||||
#import <SignalUtilitiesKit/ContactTableViewCell.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -374,6 +374,12 @@
|
|||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Previews are supported for most urls.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Link Previews";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Voice and video calls";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Allow access to accept voice and video calls from other users.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Calls";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Notification Content";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -562,6 +568,8 @@
|
|||
"modal_link_previews_title" = "Enable Link Previews?";
|
||||
"modal_link_previews_explanation" = "Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session's settings.";
|
||||
"modal_link_previews_button_title" = "Enable";
|
||||
"modal_call_title" = "Voice / video calls";
|
||||
"modal_call_explanation" = "The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user.";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"vc_share_title" = "Share to Session";
|
||||
|
@ -598,9 +606,18 @@
|
|||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"call_outgoing" = "You called %@";
|
||||
"call_incoming" = "%@ called you";
|
||||
"call_missed" = "Missed Call from %@";
|
||||
"call_rejected" = "Rejected Call";
|
||||
"call_cancelled" = "Cancelled Call";
|
||||
"call_timeout" = "Unanswered Call";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message.";
|
||||
"APN_Collapsed_Messages" = "You've got %@ new messages.";
|
||||
"system_mode_theme" = "System";
|
||||
|
@ -608,6 +625,8 @@
|
|||
"light_mode_theme" = "Light";
|
||||
"PIN_BUTTON_TEXT" = "Pin";
|
||||
"UNPIN_BUTTON_TEXT" = "Unpin";
|
||||
"modal_call_missed_tips_title" = "Call missed";
|
||||
"modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"meida_saved" = "Media saved by %@.";
|
||||
"screenshot_taken" = "%@ took a screenshot.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
|
||||
|
@ -630,5 +649,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
|
@ -597,9 +597,12 @@
|
|||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
|
@ -620,5 +623,7 @@
|
|||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue