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:
Morgan Pretty 2022-04-26 13:06:12 +10:00
commit 1c575e520d
188 changed files with 6111 additions and 1096 deletions

20
Podfile
View File

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

View File

@ -21,6 +21,7 @@ PODS:
- Curve25519Kit (2.1.0):
- CocoaLumberjack
- SignalCoreKit
- GoogleWebRTC (1.1.31999)
- Mantle (2.1.0):
- Mantle/extobjc (= 2.1.0)
- Mantle/extobjc (2.1.0)
@ -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

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1140"
LastUpgradeVersion = "1320"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1320"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1210"
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

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

View File

@ -0,0 +1,357 @@
import Foundation
import WebRTC
import SessionMessagingKit
import PromiseKit
import CallKit
public final class SessionCall: NSObject, WebRTCSessionDelegate {
@objc static let isEnabled = true
// MARK: Metadata Properties
let uuid: String
let callID: UUID // This is for CallKit
let sessionID: String
let mode: Mode
var audioMode: AudioMode
let webRTCSession: WebRTCSession
let isOutgoing: Bool
var remoteSDP: RTCSessionDescription? = nil
var callMessageID: String?
var answerCallAction: CXAnswerCallAction? = nil
var contactName: String {
let contact = Storage.shared.getContact(with: self.sessionID)
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
}
var profilePicture: UIImage {
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
return result
} else {
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
}
}
// MARK: Control
lazy public var videoCapturer: RTCVideoCapturer = {
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
}()
var isRemoteVideoEnabled = false {
didSet {
remoteVideoStateDidChange?(isRemoteVideoEnabled)
}
}
var isMuted = false {
willSet {
if newValue {
webRTCSession.mute()
} else {
webRTCSession.unmute()
}
}
}
var isVideoEnabled = false {
willSet {
if newValue {
webRTCSession.turnOnVideo()
} else {
webRTCSession.turnOffVideo()
}
}
}
// MARK: Mode
enum Mode {
case offer
case answer
}
// MARK: End call mode
enum EndCallMode {
case local
case remote
case unanswered
case answeredElsewhere
}
// MARK: Audio I/O mode
enum AudioMode {
case earpiece
case speaker
case headphone
case bluetooth
}
// MARK: Call State Properties
var connectingDate: Date? {
didSet {
stateDidChange?()
hasStartedConnectingDidChange?()
}
}
var connectedDate: Date? {
didSet {
stateDidChange?()
hasConnectedDidChange?()
}
}
var endDate: Date? {
didSet {
stateDidChange?()
hasEndedDidChange?()
}
}
// Not yet implemented
var isOnHold = false {
didSet {
stateDidChange?()
}
}
// MARK: State Change Callbacks
var stateDidChange: (() -> Void)?
var hasStartedConnectingDidChange: (() -> Void)?
var hasConnectedDidChange: (() -> Void)?
var hasEndedDidChange: (() -> Void)?
var remoteVideoStateDidChange: ((Bool) -> Void)?
var hasStartedReconnecting: (() -> Void)?
var hasReconnected: (() -> Void)?
// MARK: Derived Properties
var hasStartedConnecting: Bool {
get { return connectingDate != nil }
set { connectingDate = newValue ? Date() : nil }
}
var hasConnected: Bool {
get { return connectedDate != nil }
set { connectedDate = newValue ? Date() : nil }
}
var hasEnded: Bool {
get { return endDate != nil }
set { endDate = newValue ? Date() : nil }
}
var timeOutTimer: Timer? = nil
var didTimeout = false
var duration: TimeInterval {
guard let connectedDate = connectedDate else {
return 0
}
if let endDate = endDate {
return endDate.timeIntervalSince(connectedDate)
}
return Date().timeIntervalSince(connectedDate)
}
var reconnectTimer: Timer? = nil
// MARK: Initialization
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
self.sessionID = sessionID
self.uuid = uuid
self.callID = UUID()
self.mode = mode
self.audioMode = .earpiece
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
self.isOutgoing = outgoing
WebRTCSession.current = self.webRTCSession
super.init()
self.webRTCSession.delegate = self
if AppEnvironment.shared.callManager.currentCall == nil {
AppEnvironment.shared.callManager.currentCall = self
} else {
SNLog("[Calls] A call is ongoing.")
}
}
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
guard case .answer = mode else { return }
setupTimeoutTimer()
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
completion(error)
}
}
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
SNLog("[Calls] Did receive remote sdp.")
remoteSDP = sdp
if hasStartedConnecting {
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
}
}
// MARK: Actions
func startSessionCall() {
guard case .offer = mode else { return }
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
let message = CallMessage()
message.sender = getUserHexEncodedPublicKey()
message.sentTimestamp = NSDate.millisecondTimestamp()
message.uuid = self.uuid
message.kind = .preOffer
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
infoMessage.save()
self.callMessageID = infoMessage.uniqueId
var promise: Promise<Void>!
Storage.write(with: { transaction in
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
}, completion: { [weak self] in
let _ = promise.done {
Storage.shared.write { transaction in
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
}
self?.setupTimeoutTimer()
}
})
}
func answerSessionCall() {
guard case .answer = mode else { return }
hasStartedConnecting = true
if let sdp = remoteSDP {
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
}
}
func answerSessionCallInBackground(action: CXAnswerCallAction) {
answerCallAction = action
self.answerSessionCall()
}
func endSessionCall() {
guard !hasEnded else { return }
webRTCSession.hangUp()
Storage.write { transaction in
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
}
hasEnded = true
}
// MARK: Update call message
func updateCallMessage(mode: EndCallMode) {
guard let callMessageID = callMessageID else { return }
Storage.write { transaction in
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
if let messageToUpdate = infoMessage {
var shouldMarkAsRead = false
if self.duration > 0 {
shouldMarkAsRead = true
} else if self.hasStartedConnecting {
shouldMarkAsRead = true
} else {
switch mode {
case .local:
shouldMarkAsRead = true
fallthrough
case .remote:
fallthrough
case .unanswered:
if messageToUpdate.callState == .incoming {
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
}
case .answeredElsewhere:
shouldMarkAsRead = true
}
}
if shouldMarkAsRead {
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
}
}
}
}
// MARK: Renderer
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.attachRemoteRenderer(renderer)
}
func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.removeRemoteRenderer(renderer)
}
func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.attachLocalRenderer(renderer)
}
// MARK: Delegate
public func webRTCIsConnected() {
self.invalidateTimeoutTimer()
self.reconnectTimer?.invalidate()
guard !self.hasConnected else {
hasReconnected?()
return
}
self.hasConnected = true
self.answerCallAction?.fulfill()
}
public func isRemoteVideoDidChange(isEnabled: Bool) {
isRemoteVideoEnabled = isEnabled
}
public func didReceiveHangUpSignal() {
self.hasEnded = true
DispatchQueue.main.async {
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
}
}
public func dataChannelDidOpen() {
// Send initial video status
if (isVideoEnabled) {
webRTCSession.turnOnVideo()
} else {
webRTCSession.turnOffVideo()
}
}
public func reconnectIfNeeded() {
setupTimeoutTimer()
hasStartedReconnecting?()
guard isOutgoing else { return }
tryToReconnect()
}
private func tryToReconnect() {
reconnectTimer?.invalidate()
if SSKEnvironment.shared.reachabilityManager.isReachable {
Storage.write { transaction in
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
}
} else {
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
self.tryToReconnect()
}
}
}
// MARK: Timeout
public func setupTimeoutTimer() {
invalidateTimeoutTimer()
let timeInterval: TimeInterval = hasConnected ? 60 : 30
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
self.didTimeout = true
AppEnvironment.shared.callManager.endCall(self) { error in
self.timeOutTimer = nil
}
}
}
public func invalidateTimeoutTimer() {
timeOutTimer?.invalidate()
timeOutTimer = nil
}
}

View File

@ -0,0 +1,47 @@
extension SessionCallManager {
@discardableResult
public func startCallAction() -> Bool {
guard let call = self.currentCall else { return false }
call.startSessionCall()
return true
}
@discardableResult
public func answerCallAction() -> Bool {
guard let call = self.currentCall else { return false }
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
call.answerSessionCall()
} else {
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
let callVC = CallVC(for: self.currentCall!)
if let conversationVC = presentingVC as? ConversationVC {
callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0
}
presentingVC.present(callVC, animated: true) {
call.answerSessionCall()
}
}
return true
}
@discardableResult
public func endCallAction() -> Bool {
guard let call = self.currentCall else { return false }
call.endSessionCall()
if call.didTimeout {
reportCurrentCallEnded(reason: .unanswered)
} else {
reportCurrentCallEnded(reason: nil)
}
return true
}
@discardableResult
public func setMutedCallAction(isMuted: Bool) -> Bool {
guard let call = self.currentCall else { return false }
call.isMuted = isMuted
return true
}
}

View File

@ -0,0 +1,72 @@
import CallKit
import SessionUtilitiesKit
extension SessionCallManager {
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
guard case .offer = call.mode else { return }
guard !call.hasConnected else { return }
reportOutgoingCall(call)
if callController != nil {
let handle = CXHandle(type: .generic, value: call.sessionID)
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
startCallAction.isVideo = false
let transaction = CXTransaction()
transaction.addAction(startCallAction)
requestTransaction(transaction, completion: completion)
} else {
startCallAction()
completion?(nil)
}
}
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
if callController != nil {
let answerCallAction = CXAnswerCallAction(call: call.callID)
let transaction = CXTransaction()
transaction.addAction(answerCallAction)
requestTransaction(transaction, completion: completion)
} else {
answerCallAction()
completion?(nil)
}
}
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
if callController != nil {
let endCallAction = CXEndCallAction(call: call.callID)
let transaction = CXTransaction()
transaction.addAction(endCallAction)
requestTransaction(transaction, completion: completion)
} else {
endCallAction()
completion?(nil)
}
}
// Not currently in use
public func setOnHoldStatus(for call: SessionCall) {
if callController != nil {
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
let transaction = CXTransaction()
transaction.addAction(setHeldCallAction)
requestTransaction(transaction)
}
}
private func requestTransaction(_ transaction: CXTransaction, completion: ((Error?) -> Void)? = nil) {
callController?.request(transaction) { error in
if let error = error {
SNLog("Error requesting transaction: \(error)")
} else {
SNLog("Requested transaction successfully")
}
completion?(error)
}
}
}

View File

@ -0,0 +1,76 @@
import CallKit
extension SessionCallManager: CXProviderDelegate {
public func providerDidReset(_ provider: CXProvider) {
AssertIsOnMainThread()
currentCall?.endSessionCall()
}
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
AssertIsOnMainThread()
if startCallAction() {
action.fulfill()
} else {
action.fail()
}
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
AssertIsOnMainThread()
print("[CallKit] Perform CXAnswerCallAction")
guard let call = self.currentCall else { return action.fail() }
if CurrentAppContext().isMainAppAndActive {
if answerCallAction() {
action.fulfill()
} else {
action.fail()
}
} else {
call.answerSessionCallInBackground(action: action)
}
}
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
print("[CallKit] Perform CXEndCallAction")
AssertIsOnMainThread()
if endCallAction() {
action.fulfill()
} else {
action.fail()
}
}
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
AssertIsOnMainThread()
if setMutedCallAction(isMuted: action.isMuted) {
action.fulfill()
} else {
action.fail()
}
}
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
// TODO: set on hold
}
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
// TODO: handle timeout
}
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
print("[CallKit] Audio session did activate.")
AssertIsOnMainThread()
guard let call = self.currentCall else { return }
call.webRTCSession.audioSessionDidActivate(audioSession)
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
}
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
print("[CallKit] Audio session did deactivate.")
AssertIsOnMainThread()
guard let call = self.currentCall else { return }
call.webRTCSession.audioSessionDidDeactivate(audioSession)
}
}

View File

@ -0,0 +1,152 @@
import CallKit
import SessionMessagingKit
public final class SessionCallManager: NSObject {
let provider: CXProvider?
let callController: CXCallController?
var currentCall: SessionCall? = nil {
willSet {
if (newValue != nil) {
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}
} else {
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = false
}
}
}
}
private static var _sharedProvider: CXProvider?
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
if let sharedProvider = self._sharedProvider {
sharedProvider.configuration = configuration
return sharedProvider
} else {
SwiftSingletons.register(self)
let provider = CXProvider(configuration: configuration)
_sharedProvider = provider
return provider
}
}
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
providerConfiguration.supportsVideo = true
providerConfiguration.maximumCallGroups = 1
providerConfiguration.maximumCallsPerCallGroup = 1
providerConfiguration.supportedHandleTypes = [.generic]
let iconMaskImage = #imageLiteral(resourceName: "SessionGreen32")
providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()
providerConfiguration.includesCallsInRecents = useSystemCallLog
return providerConfiguration
}
init(useSystemCallLog: Bool = false) {
AssertIsOnMainThread()
if SSKPreferences.isCallKitSupported {
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
self.callController = CXCallController()
} else {
self.provider = nil
self.callController = nil
}
super.init()
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
self.provider?.setDelegate(self, queue: nil)
}
// MARK: Report calls
public func reportOutgoingCall(_ call: SessionCall) {
AssertIsOnMainThread()
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
call.stateDidChange = {
if call.hasStartedConnecting {
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
}
if call.hasConnected {
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
}
}
}
public func reportIncomingCall(_ call: SessionCall, callerName: String, completion: @escaping (Error?) -> Void) {
AssertIsOnMainThread()
if let provider = provider {
// Construct a CXCallUpdate describing the incoming call, including the caller.
let update = CXCallUpdate()
update.localizedCallerName = callerName
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
update.hasVideo = false
disableUnsupportedFeatures(callUpdate: update)
// Report the incoming call to the system
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
guard error == nil else {
self.reportCurrentCallEnded(reason: .failed)
completion(error)
return
}
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
completion(nil)
}
} else {
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
completion(nil)
}
}
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
guard let call = currentCall else { return }
if let reason = reason {
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
case .unanswered: call.updateCallMessage(mode: .unanswered)
case .declinedElsewhere: call.updateCallMessage(mode: .local)
default: call.updateCallMessage(mode: .remote)
}
} else {
call.updateCallMessage(mode: .local)
}
call.webRTCSession.dropConnection()
self.currentCall = nil
WebRTCSession.current = nil
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
}
// MARK: Util
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
// until user returns to in-app call screen.
callUpdate.supportsHolding = false
// Not yet supported
callUpdate.supportsGrouping = false
callUpdate.supportsUngrouping = false
// Is there any reason to support this?
callUpdate.supportsDTMF = false
}
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
let message = CallMessage()
message.uuid = offerMessage.uuid
message.kind = .endCall
SNLog("[Calls] Sending end call message because there is an ongoing call.")
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
infoMessage.updateCallInfoMessage(.missed, using: transaction)
}
}

View File

@ -0,0 +1,22 @@
import WebRTC
extension CallVC : CameraManagerDelegate {
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
let timestampNs = Int64(timestamp * 1000000000)
let rotation: RTCVideoRotation = {
switch UIDevice.current.orientation {
case .landscapeRight: return RTCVideoRotation._90
case .portraitUpsideDown: return RTCVideoRotation._180
case .landscapeLeft: return RTCVideoRotation._270
default: return RTCVideoRotation._0
}
}()
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs)
frame.timeStamp = Int32(timestamp)
call.webRTCSession.handleLocalFrameCaptured(frame)
}
}

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

@ -0,0 +1,550 @@
import WebRTC
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import UIKit
import MediaPlayer
final class CallVC : UIViewController, VideoPreviewDelegate {
let call: SessionCall
var latestKnownAudioOutputDeviceName: String?
var durationTimer: Timer?
var duration: Int = 0
var shouldRestartCamera = true
weak var conversationVC: ConversationVC? = nil
lazy var cameraManager: CameraManager = {
let result = CameraManager()
result.delegate = self
return result
}()
// MARK: UI Components
private lazy var localVideoView: LocalVideoView = {
let result = LocalVideoView()
result.isHidden = !call.isVideoEnabled
result.layer.cornerRadius = 10
result.layer.masksToBounds = true
result.set(.width, to: LocalVideoView.width)
result.set(.height, to: LocalVideoView.height)
result.makeViewDraggable()
return result
}()
private lazy var remoteVideoView: RemoteVideoView = {
let result = RemoteVideoView()
result.alpha = 0
result.backgroundColor = .black
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped)))
return result
}()
private lazy var fadeView: UIView = {
let result = UIView()
let height: CGFloat = 64
var frame = UIScreen.main.bounds
frame.size.height = height
let layer = CAGradientLayer()
layer.frame = frame
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
result.layer.insertSublayer(layer, at: 0)
result.set(.height, to: height)
return result
}()
private lazy var profilePictureView: UIImageView = {
let result = UIImageView()
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
result.image = self.call.profilePicture
result.set(.width, to: radius * 2)
result.set(.height, to: radius * 2)
result.layer.cornerRadius = radius
result.layer.masksToBounds = true
result.contentMode = .scaleAspectFill
return result
}()
private lazy var minimizeButton: UIButton = {
let result = UIButton(type: .custom)
result.isHidden = !call.hasConnected
let image = UIImage(named: "Minimize")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var answerButton: UIButton = {
let result = UIButton(type: .custom)
result.isHidden = call.hasStartedConnecting
let image = UIImage(named: "AnswerCall")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.backgroundColor = Colors.accent
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var hangUpButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "EndCall")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.backgroundColor = Colors.destructive
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var responsePanel: UIStackView = {
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
result.axis = .horizontal
result.spacing = Values.veryLargeSpacing * 2 + 40
return result
}()
private lazy var switchCameraButton: UIButton = {
let result = UIButton(type: .custom)
result.isEnabled = call.isVideoEnabled
let image = UIImage(named: "SwitchCamera")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.backgroundColor = UIColor(hex: 0x1F1F1F)
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var switchAudioButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "AudioOff")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.backgroundColor = call.isMuted ? Colors.destructive : UIColor(hex: 0x1F1F1F)
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var videoButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "VideoCall")?.withRenderingMode(.alwaysTemplate)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.tintColor = .white
result.backgroundColor = UIColor(hex: 0x1F1F1F)
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var volumeView: MPVolumeView = {
let result = MPVolumeView()
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
result.showsVolumeSlider = false
result.showsRouteButton = true
result.setRouteButtonImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.tintColor = .white
result.backgroundColor = UIColor(hex: 0x1F1F1F)
result.layer.cornerRadius = 30
return result
}()
private lazy var operationPanel: UIStackView = {
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
result.axis = .horizontal
result.spacing = Values.veryLargeSpacing
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.textColor = .white
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.textAlignment = .center
return result
}()
private lazy var callInfoLabel: UILabel = {
let result = UILabel()
result.isHidden = call.hasConnected
result.textColor = .white
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.textAlignment = .center
if call.hasStartedConnecting { result.text = "Connecting..." }
return result
}()
private lazy var callDurationLabel: UILabel = {
let result = UILabel()
result.isHidden = true
result.textColor = .white
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.textAlignment = .center
return result
}()
// MARK: Lifecycle
init(for call: SessionCall) {
self.call = call
super.init(nibName: nil, bundle: nil)
setupStateChangeCallbacks()
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
}
func setupStateChangeCallbacks() {
self.call.remoteVideoStateDidChange = { isEnabled in
DispatchQueue.main.async {
UIView.animate(withDuration: 0.25) {
self.remoteVideoView.alpha = isEnabled ? 1 : 0
}
if self.callInfoLabel.alpha < 0.5 {
UIView.animate(withDuration: 0.25) {
self.operationPanel.alpha = 1
self.responsePanel.alpha = 1
self.callInfoLabel.alpha = 1
}
}
}
}
self.call.hasStartedConnectingDidChange = {
DispatchQueue.main.async {
self.callInfoLabel.text = "Connecting..."
self.answerButton.alpha = 0
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseIn, animations: {
self.answerButton.isHidden = true
}, completion: nil)
}
}
self.call.hasConnectedDidChange = {
DispatchQueue.main.async {
CallRingTonePlayer.shared.stopPlayingRingTone()
self.callInfoLabel.text = "Connected"
self.minimizeButton.isHidden = false
self.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.updateDuration()
}
self.callInfoLabel.isHidden = true
self.callDurationLabel.isHidden = false
}
}
self.call.hasEndedDidChange = {
DispatchQueue.main.async {
self.durationTimer?.invalidate()
self.durationTimer = nil
self.handleEndCallMessage()
}
}
self.call.hasStartedReconnecting = {
DispatchQueue.main.async {
self.callInfoLabel.isHidden = false
self.callDurationLabel.isHidden = true
self.callInfoLabel.text = "Reconnecting..."
}
}
self.call.hasReconnected = {
DispatchQueue.main.async {
self.callInfoLabel.isHidden = true
self.callDurationLabel.isHidden = false
}
}
}
required init(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setUpViewHierarchy()
if shouldRestartCamera { cameraManager.prepare() }
touch(call.videoCapturer)
titleLabel.text = self.call.contactName
AppEnvironment.shared.callManager.startCall(call) { error in
DispatchQueue.main.async {
if let _ = error {
self.callInfoLabel.text = "Can't start a call."
self.endCall()
} else {
self.callInfoLabel.text = "Ringing..."
self.answerButton.isHidden = true
}
}
}
setupOrientationMonitoring()
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
}
deinit {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
NotificationCenter.default.removeObserver(self)
}
func setUpViewHierarchy() {
// Profile picture container
let profilePictureContainer = UIView()
view.addSubview(profilePictureContainer)
// Remote video view
call.attachRemoteVideoRenderer(remoteVideoView)
view.addSubview(remoteVideoView)
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
remoteVideoView.pin(to: view)
// Local video view
call.attachLocalVideoRenderer(localVideoView)
// Fade view
view.addSubview(fadeView)
fadeView.translatesAutoresizingMaskIntoConstraints = false
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
// Minimize button
view.addSubview(minimizeButton)
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
minimizeButton.pin(.left, to: .left, of: view)
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
// Title label
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.center(.vertical, in: minimizeButton)
titleLabel.center(.horizontal, in: view)
// Response Panel
view.addSubview(responsePanel)
responsePanel.center(.horizontal, in: view)
responsePanel.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset)
// Operation Panel
view.addSubview(operationPanel)
operationPanel.center(.horizontal, in: view)
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
// Profile picture view
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
profilePictureContainer.addSubview(profilePictureView)
profilePictureView.center(in: profilePictureContainer)
// Call info label
let callInfoLabelContainer = UIView()
view.addSubview(callInfoLabelContainer)
callInfoLabelContainer.pin(.top, to: .bottom, of: profilePictureView)
callInfoLabelContainer.pin(.bottom, to: .bottom, of: profilePictureContainer)
callInfoLabelContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
callInfoLabelContainer.addSubview(callInfoLabel)
callInfoLabelContainer.addSubview(callDurationLabel)
callInfoLabel.translatesAutoresizingMaskIntoConstraints = false
callInfoLabel.center(in: callInfoLabelContainer)
callDurationLabel.translatesAutoresizingMaskIntoConstraints = false
callDurationLabel.center(in: callInfoLabelContainer)
}
private func addLocalVideoView() {
let safeAreaInsets = UIApplication.shared.keyWindow!.safeAreaInsets
let window = CurrentAppContext().mainWindow!
window.addSubview(localVideoView)
localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
let topMargin = safeAreaInsets.top + Values.veryLargeSpacing
localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
shouldRestartCamera = true
addLocalVideoView()
remoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
localVideoView.removeFromSuperview()
}
// MARK: - Orientation
private func setupOrientationMonitoring() {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
}
@objc func didChangeDeviceOrientation(notification: Notification) {
func rotateAllButtons(rotationAngle: CGFloat) {
let transform = CGAffineTransform(rotationAngle: rotationAngle)
UIView.animate(withDuration: 0.2) {
self.answerButton.transform = transform
self.hangUpButton.transform = transform
self.switchAudioButton.transform = transform
self.switchCameraButton.transform = transform
self.videoButton.transform = transform
self.volumeView.transform = transform
}
}
switch UIDevice.current.orientation {
case .portrait:
rotateAllButtons(rotationAngle: 0)
case .portraitUpsideDown:
rotateAllButtons(rotationAngle: .pi)
case .landscapeLeft:
rotateAllButtons(rotationAngle: .halfPi)
case .landscapeRight:
rotateAllButtons(rotationAngle: .pi + .halfPi)
default:
break
}
}
// MARK: Call signalling
func handleAnswerMessage(_ message: CallMessage) {
callInfoLabel.text = "Connecting..."
}
func handleEndCallMessage() {
SNLog("[Calls] Ending call.")
self.callInfoLabel.isHidden = false
self.callDurationLabel.isHidden = true
callInfoLabel.text = "Call Ended"
UIView.animate(withDuration: 0.25) {
self.remoteVideoView.alpha = 0
self.operationPanel.alpha = 1
self.responsePanel.alpha = 1
self.callInfoLabel.alpha = 1
}
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.conversationVC?.showInputAccessoryView()
self.presentingViewController?.dismiss(animated: true, completion: nil)
}
}
@objc private func answerCall() {
AppEnvironment.shared.callManager.answerCall(call) { error in
DispatchQueue.main.async {
if let _ = error {
self.callInfoLabel.text = "Can't answer the call."
self.endCall()
}
}
}
}
@objc private func endCall() {
AppEnvironment.shared.callManager.endCall(call) { error in
if let _ = error {
self.call.endSessionCall()
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
}
DispatchQueue.main.async {
self.conversationVC?.showInputAccessoryView()
self.presentingViewController?.dismiss(animated: true, completion: nil)
}
}
}
@objc private func updateDuration() {
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
duration += 1
}
// MARK: Minimize to a floating view
@objc private func minimize() {
self.shouldRestartCamera = false
let miniCallView = MiniCallView(from: self)
miniCallView.show()
self.conversationVC?.showInputAccessoryView()
presentingViewController?.dismiss(animated: true, completion: nil)
}
// MARK: Video and Audio
@objc private func operateCamera() {
if (call.isVideoEnabled) {
localVideoView.isHidden = true
cameraManager.stop()
videoButton.tintColor = .white
videoButton.backgroundColor = UIColor(hex: 0x1F1F1F)
switchCameraButton.isEnabled = false
call.isVideoEnabled = false
} else {
guard requestCameraPermissionIfNeeded() else { return }
let previewVC = VideoPreviewVC()
previewVC.delegate = self
present(previewVC, animated: true, completion: nil)
}
}
func cameraDidConfirmTurningOn() {
localVideoView.isHidden = false
cameraManager.prepare()
cameraManager.start()
videoButton.tintColor = UIColor(hex: 0x1F1F1F)
videoButton.backgroundColor = .white
switchCameraButton.isEnabled = true
call.isVideoEnabled = true
}
@objc private func switchCamera() {
cameraManager.switchCamera()
}
@objc private func switchAudio() {
if call.isMuted {
switchAudioButton.backgroundColor = UIColor(hex: 0x1F1F1F)
call.isMuted = false
} else {
switchAudioButton.backgroundColor = Colors.destructive
call.isMuted = true
}
}
@objc private func audioRouteDidChange() {
let currentSession = AVAudioSession.sharedInstance()
let currentRoute = currentSession.currentRoute
if let currentOutput = currentRoute.outputs.first {
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
latestKnownAudioOutputDeviceName = currentOutput.portName
switch currentOutput.portType {
case .builtInSpeaker:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
volumeView.backgroundColor = .white
case .headphones:
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
volumeView.backgroundColor = .white
case .bluetoothLE: fallthrough
case .bluetoothA2DP:
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
volumeView.backgroundColor = .white
case .bluetoothHFP:
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
volumeView.backgroundColor = .white
case .builtInReceiver: fallthrough
default:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.tintColor = .white
volumeView.backgroundColor = UIColor(hex: 0x1F1F1F)
}
}
}
@objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) {
let isHidden = callDurationLabel.alpha < 0.5
UIView.animate(withDuration: 0.5) {
self.operationPanel.alpha = isHidden ? 1 : 0
self.responsePanel.alpha = isHidden ? 1 : 0
self.callDurationLabel.alpha = isHidden ? 1 : 0
}
}
}

View File

@ -0,0 +1,88 @@
import Foundation
import AVFoundation
import SessionUtilitiesKit
@objc
protocol CameraManagerDelegate : AnyObject {
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer)
}
final class CameraManager : NSObject {
private let captureSession = AVCaptureSession()
private let videoDataOutput = AVCaptureVideoDataOutput()
private let videoDataOutputQueue
= DispatchQueue(label: "CameraManager.videoDataOutputQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
private let audioDataOutput = AVCaptureAudioDataOutput()
private var isCapturing = false
weak var delegate: CameraManagerDelegate?
private var videoCaptureDevice: AVCaptureDevice?
private var videoInput: AVCaptureDeviceInput?
func prepare() {
print("[Calls] Preparing camera.")
addNewVideoIO(position: .front)
}
private func addNewVideoIO(position: AVCaptureDevice.Position) {
if let videoCaptureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), captureSession.canAddInput(videoInput) {
captureSession.addInput(videoInput)
self.videoCaptureDevice = videoCaptureDevice
self.videoInput = videoInput
}
if captureSession.canAddOutput(videoDataOutput) {
captureSession.addOutput(videoDataOutput)
videoDataOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String : Int(kCVPixelFormatType_32BGRA) ]
videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
guard let connection = videoDataOutput.connection(with: AVMediaType.video) else { return }
connection.videoOrientation = .portrait
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = (position == .front)
} else {
SNLog("Couldn't add video data output to capture session.")
}
}
func start() {
guard !isCapturing else { return }
print("[Calls] Starting camera.")
isCapturing = true
captureSession.startRunning()
}
func stop() {
guard isCapturing else { return }
print("[Calls] Stopping camera.")
isCapturing = false
captureSession.stopRunning()
}
func switchCamera() {
guard let videoCaptureDevice = videoCaptureDevice, let videoInput = videoInput else { return }
stop()
if videoCaptureDevice.position == .front {
captureSession.removeInput(videoInput)
captureSession.removeOutput(videoDataOutput)
addNewVideoIO(position: .back)
} else {
captureSession.removeInput(videoInput)
captureSession.removeOutput(videoDataOutput)
addNewVideoIO(position: .front)
}
start()
}
}
extension CameraManager : AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard connection == videoDataOutput.connection(with: .video) else { return }
delegate?.handleVideoOutputCaptured(sampleBuffer: sampleBuffer)
}
func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
print("[Calls] Frame dropped.")
}
}

View File

@ -0,0 +1,123 @@
import UIKit
import WebRTC
public protocol VideoPreviewDelegate : AnyObject {
func cameraDidConfirmTurningOn()
}
class VideoPreviewVC: UIViewController, CameraManagerDelegate {
weak var delegate: VideoPreviewDelegate?
lazy var cameraManager: CameraManager = {
let result = CameraManager()
result.delegate = self
return result
}()
// MARK: UI Components
private lazy var renderView: RenderView = {
let result = RenderView()
return result
}()
private lazy var fadeView: UIView = {
let result = UIView()
let height: CGFloat = 64
var frame = UIScreen.main.bounds
frame.size.height = height
let layer = CAGradientLayer()
layer.frame = frame
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
result.layer.insertSublayer(layer, at: 0)
result.set(.height, to: height)
return result
}()
private lazy var closeButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "X")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var confirmButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "Check")!.withTint(.white)
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 60)
result.set(.height, to: 60)
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.text = "Preview"
result.textColor = .white
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.textAlignment = .center
return result
}()
// MARK: Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setUpViewHierarchy()
cameraManager.prepare()
}
func setUpViewHierarchy() {
// Preview video view
view.addSubview(renderView)
renderView.translatesAutoresizingMaskIntoConstraints = false
renderView.pin(to: view)
// Fade view
view.addSubview(fadeView)
fadeView.translatesAutoresizingMaskIntoConstraints = false
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
// Close button
view.addSubview(closeButton)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.pin(.left, to: .left, of: view)
closeButton.center(.vertical, in: fadeView)
// Confirm button
view.addSubview(confirmButton)
confirmButton.translatesAutoresizingMaskIntoConstraints = false
confirmButton.pin(.right, to: .right, of: view)
confirmButton.center(.vertical, in: fadeView)
// Title label
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.center(.vertical, in: closeButton)
titleLabel.center(.horizontal, in: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
cameraManager.start()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
cameraManager.stop()
}
// MARK: Interaction
@objc func confirm() {
delegate?.cameraDidConfirmTurningOn()
self.dismiss(animated: true, completion: nil)
}
@objc func cancel() {
self.dismiss(animated: true, completion: nil)
}
// MARK: CameraManagerDelegate
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
renderView.enqueue(sampleBuffer: sampleBuffer)
}
}

View File

@ -0,0 +1,57 @@
import UIKit
@objc
final class CallMissedTipsModal : Modal {
private let caller: String
// MARK: Lifecycle
@objc
init(caller: String) {
self.caller = caller
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override func populateContentView() {
// Tips icon
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
tipsIconImageView.set(.width, to: 19)
tipsIconImageView.set(.height, to: 28)
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
titleLabel.textAlignment = .center
// Message
let messageLabel = UILabel()
messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
messageLabel.text = message
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .natural
// Cancel Button
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
mainStackView.axis = .vertical
mainStackView.alignment = .center
mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
}
}

View File

@ -0,0 +1,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
}
}
}

View File

@ -0,0 +1,191 @@
import UIKit
import WebRTC
import SessionMessagingKit
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
private static let swipeToOperateThreshold: CGFloat = 60
private var previousY: CGFloat = 0
let call: SessionCall
// MARK: UI Components
private lazy var profilePictureView: ProfilePictureView = {
let result = ProfilePictureView()
let size = CGFloat(60)
result.size = size
result.set(.width, to: size)
result.set(.height, to: size)
return result
}()
private lazy var displayNameLabel: UILabel = {
let result = UILabel()
result.textColor = UIColor.white
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var answerButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "AnswerCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 24.8, height: 24.8))
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 48)
result.set(.height, to: 48)
result.backgroundColor = Colors.accent
result.layer.cornerRadius = 24
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var hangUpButton: UIButton = {
let result = UIButton(type: .custom)
let image = UIImage(named: "EndCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 29.6, height: 11.2))
result.setImage(image, for: UIControl.State.normal)
result.set(.width, to: 48)
result.set(.height, to: 48)
result.backgroundColor = Colors.destructive
result.layer.cornerRadius = 24
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
result.delegate = self
return result
}()
// MARK: Initialization
public static var current: IncomingCallBanner?
init(for call: SessionCall) {
self.call = call
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpGestureRecognizers()
if let incomingCallBanner = IncomingCallBanner.current {
incomingCallBanner.dismiss()
}
IncomingCallBanner.current = self
}
override init(frame: CGRect) {
preconditionFailure("Use init(message:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
private func setUpViewHierarchy() {
self.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(0.8)
self.layer.cornerRadius = Values.largeSpacing
self.layer.masksToBounds = true
self.set(.height, to: 100)
profilePictureView.publicKey = call.sessionID
profilePictureView.update()
displayNameLabel.text = call.contactName
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = Values.largeSpacing
self.addSubview(stackView)
stackView.center(.vertical, in: self)
stackView.autoPinWidthToSuperview(withMargin: Values.mediumSpacing)
}
private func setUpGestureRecognizers() {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(tapGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
}
// MARK: Interaction
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == panGestureRecognizer {
let v = panGestureRecognizer.velocity(in: self)
return abs(v.y) > abs(v.x) // It has to be more vertical than horizontal
} else {
return true
}
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
showCallVC(answer: false)
}
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
let translationY = gestureRecognizer.translation(in: self).y
switch gestureRecognizer.state {
case .changed:
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
}
previousY = translationY
case .ended, .cancelled:
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
if translationY > 0 { showCallVC(answer: false) }
else { endCall() } // TODO: Or just put the call on hold?
} else {
self.transform = .identity
}
default: break
}
}
@objc private func answerCall() {
showCallVC(answer: true)
}
@objc private func endCall() {
AppEnvironment.shared.callManager.endCall(call) { error in
if let _ = error {
self.call.endSessionCall()
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
}
self.dismiss()
}
}
public func showCallVC(answer: Bool) {
dismiss()
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
let callVC = CallVC(for: self.call)
if let conversationVC = presentingVC as? ConversationVC {
callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0
}
presentingVC.present(callVC, animated: true) {
if answer { self.call.answerSessionCall() }
}
}
public func show() {
self.alpha = 0.0
let window = CurrentAppContext().mainWindow!
window.addSubview(self)
let topMargin = window.safeAreaInsets.top - Values.smallSpacing
self.autoPinWidthToSuperview(withMargin: Values.smallSpacing)
self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
self.alpha = 1.0
}, completion: nil)
CallRingTonePlayer.shared.startVibration()
CallRingTonePlayer.shared.startPlayingRingTone()
}
public func dismiss() {
CallRingTonePlayer.shared.stopVibrationIfPossible()
CallRingTonePlayer.shared.stopPlayingRingTone()
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
self.alpha = 0.0
}, completion: { _ in
IncomingCallBanner.current = nil
self.removeFromSuperview()
})
}
}

View File

@ -0,0 +1,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
}
}
}

View File

@ -0,0 +1,36 @@
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import CoreMedia
class RenderView: UIView {
private lazy var displayLayer: AVSampleBufferDisplayLayer = {
let result = AVSampleBufferDisplayLayer()
result.videoGravity = .resizeAspectFill
return result
}()
init() {
super.init(frame: CGRect.zero)
self.layer.addSublayer(displayLayer)
}
override init(frame: CGRect) {
preconditionFailure("Use init(message:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
override func layoutSubviews() {
super.layoutSubviews()
displayLayer.frame = self.bounds
}
public func enqueue(sampleBuffer: CMSampleBuffer) {
displayLayer.enqueue(sampleBuffer)
}
}

View File

@ -218,11 +218,11 @@ public class ConversationMessageMapping: NSObject {
return IndexPath(row: oldIndex, section: 0)
}
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
SNLog("Could not load view.")
return nil
}
guard let group = group else {
owsFailDebug("No group.")
SNLog("No group.")
return nil
}
@ -235,7 +235,7 @@ public class ConversationMessageMapping: NSObject {
let index = indexPtr.pointee
let threadInteractionCount = view.numberOfItems(inGroup: group)
guard index < threadInteractionCount else {
owsFailDebug("Invalid index.")
SNLog("Invalid index.")
return nil
}
// This math doesn't take into account the number of items loaded _after_ the pivot.
@ -244,7 +244,7 @@ public class ConversationMessageMapping: NSObject {
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
owsFailDebug("Couldn't find interaction.")
SNLog("Couldn't find interaction.")
return nil
}
return IndexPath(row: newIndex, section: 0)

View File

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

View File

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

View File

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

View File

@ -32,6 +32,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
return @"OWSMessageCellType_MediaMessage";
case OWSMessageCellType_OversizeTextDownloading:
return @"OWSMessageCellType_OversizeTextDownloading";
case OWSMessageCellType_DeletedMessage:
return @"OWSMessageCellType_DeletedMessage";
}
}
@ -798,42 +800,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
[UIPasteboard.generalPasteboard setData:data forPasteboardType:utiType];
}
- (void)shareMediaAction
{
if (self.attachmentPointer != nil) {
OWSFailDebug(@"Can't share not-yet-downloaded attachment");
return;
}
switch (self.messageCellType) {
case OWSMessageCellType_Unknown:
case OWSMessageCellType_TextOnlyMessage:
case OWSMessageCellType_Audio:
case OWSMessageCellType_GenericAttachment:
[AttachmentSharing showShareUIForAttachment:self.attachmentStream];
break;
case OWSMessageCellType_MediaMessage: {
// TODO: We need a "canShareMediaAction" method.
OWSAssertDebug(self.mediaAlbumItems);
NSMutableArray<TSAttachmentStream *> *attachmentStreams = [NSMutableArray new];
for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) {
if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) {
[attachmentStreams addObject:mediaAlbumItem.attachmentStream];
}
}
if (attachmentStreams.count < 1) {
OWSFailDebug(@"Can't share media album; no valid items.");
return;
}
[AttachmentSharing showShareUIForAttachments:attachmentStreams completion:nil];
break;
}
case OWSMessageCellType_OversizeTextDownloading:
OWSFailDebug(@"Can't share not-yet-downloaded attachment");
return;
}
}
- (BOOL)canCopyMedia
{
if (self.attachmentPointer != nil) {

View File

@ -165,6 +165,13 @@ public class LongTextViewController: OWSViewController {
// MARK: - Actions
@objc func shareButtonPressed() {
AttachmentSharing.showShareUI(forText: fullText)
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
if UIDevice.current.isIPad {
shareVC.excludedActivityTypes = []
shareVC.popoverPresentationController?.permittedArrowDirections = []
shareVC.popoverPresentationController?.sourceView = self.view
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
}
self.present(shareVC, animated: true, completion: nil)
}
}

View File

@ -0,0 +1,119 @@
import UIKit
import SessionMessagingKit
final class CallMessageCell : MessageCell {
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
// MARK: UI Components
private lazy var iconImageView = UIImageView()
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
private lazy var timestampLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
result.textColor = Colors.text
result.textAlignment = .center
return result
}()
private lazy var label: UILabel = {
let result = UILabel()
result.numberOfLines = 0
result.lineBreakMode = .byWordWrapping
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text
result.textAlignment = .center
return result
}()
private lazy var container: UIView = {
let result = UIView()
result.set(.height, to: 50)
result.layer.cornerRadius = 18
result.backgroundColor = Colors.callMessageBackground
result.addSubview(label)
label.autoCenterInSuperview()
result.addSubview(iconImageView)
iconImageView.autoVCenterInSuperview()
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
result.addSubview(infoImageView)
infoImageView.autoVCenterInSuperview()
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
return result
}()
private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
result.axis = .vertical
result.alignment = .center
result.spacing = Values.smallSpacing
return result
}()
// MARK: Settings
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
private static let margin = UIScreen.main.bounds.width * 0.1
override class var identifier: String { "CallMessageCell" }
// MARK: Lifecycle
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
iconImageViewWidthConstraint.isActive = true
iconImageViewHeightConstraint.isActive = true
addSubview(stackView)
container.autoPinWidthToSuperview()
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
}
override func setUpGestureRecognizers() {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(tapGestureRecognizer)
}
// MARK: Updating
override func update() {
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
let icon: UIImage?
switch message.callState {
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
default: icon = nil
}
iconImageView.image = icon
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
Storage.read { transaction in
self.label.text = message.previewText(with: transaction)
}
let date = message.dateForUI()
let description = DateUtil.formatDate(forDisplay: date)
timestampLabel.text = description
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
if shouldBeTappable {
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
}
}
}

View File

@ -0,0 +1,51 @@
final class CallMessageView : UIView {
private let viewItem: ConversationViewItem
private let textColor: UIColor
// MARK: Settings
private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem
self.textColor = textColor
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
private func setUpViewHierarchy() {
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
// Image view
let iconSize = CallMessageView.iconSize
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let imageView = UIImageView(image: icon)
imageView.contentMode = .center
let iconImageViewSize = CallMessageView.iconImageViewSize
imageView.set(.width, to: iconImageViewSize)
imageView.set(.height, to: iconImageViewSize)
// Body label
let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = message.body
titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
addSubview(stackView)
stackView.pin(to: self, withInset: Values.smallSpacing)
}
}

View File

@ -11,7 +11,7 @@ final class LinkPreviewView : UIView {
private lazy var sentLinkPreviewTextColor: UIColor = {
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
case (true, .dark), (false, .light): return .black
case (false, .light): return .black
case (true, .light): return Colors.grey
default: return .white
}

View File

@ -57,7 +57,11 @@ class MessageCell : UITableViewCell {
switch viewItem.interaction {
case is TSIncomingMessage: fallthrough
case is TSOutgoingMessage: return VisibleMessageCell.self
case is TSInfoMessage: return InfoMessageCell.self
case is TSInfoMessage:
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
return CallMessageCell.self
}
return InfoMessageCell.self
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
default: preconditionFailure()
}

View File

@ -126,7 +126,13 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
static let largeCornerRadius: CGFloat = 18
static let contactThreadHSpacing = Values.mediumSpacing
static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
static var gutterSize: CGFloat = {
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
if UIDevice.current.isIPad {
result += CGFloat(UIScreen.main.bounds.width / 2 - 88)
}
return result
}()
private var bodyLabelTextColor: UIColor {
switch (direction, AppModeManager.shared.currentAppMode) {
@ -258,7 +264,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
messageStatusImageView.tintColor = tintColor
messageStatusImageView.backgroundColor = backgroundColor
if let message = message as? TSOutgoingMessage {
messageStatusImageView.isHidden = (message.messageState == .sent && thread?.lastInteraction != message)
messageStatusImageView.isHidden = (message.isCallMessage || message.messageState == .sent && thread?.lastInteraction != message)
} else {
messageStatusImageView.isHidden = true
}
@ -276,7 +282,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing)
timerViewIncomingMessageConstraint.isActive = (direction == .incoming)
// Swipe to reply
if (message.isDeleted) {
if (message.isDeleted || message.isCallMessage) {
removeGestureRecognizer(panGestureRecognizer)
} else {
addGestureRecognizer(panGestureRecognizer)
@ -319,6 +325,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset
if let linkPreview = viewItem.linkPreview {
let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self)
linkPreviewView.layer.mask = bubbleViewMaskLayer
linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment)
snContentView.addSubview(linkPreviewView)
linkPreviewView.pin(to: snContentView)
@ -326,6 +333,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
self.bodyTextView = linkPreviewView.bodyTextView
} else if let openGroupInvitationName = message.openGroupInvitationName, let openGroupInvitationURL = message.openGroupInvitationURL {
let openGroupInvitationView = OpenGroupInvitationView(name: openGroupInvitationName, url: openGroupInvitationURL, textColor: bodyLabelTextColor, isOutgoing: isOutgoing)
openGroupInvitationView.layer.mask = bubbleViewMaskLayer
snContentView.addSubview(openGroupInvitationView)
openGroupInvitationView.pin(to: snContentView)
openGroupInvitationView.layer.mask = bubbleViewMaskLayer

View File

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

View File

@ -0,0 +1,70 @@
@objc
final class CallModal : Modal {
private let onCallEnabled: () -> Void
// MARK: Lifecycle
@objc
init(onCallEnabled: @escaping () -> Void) {
self.onCallEnabled = onCallEnabled
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override func populateContentView() {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
titleLabel.textAlignment = .center
// Message
let messageLabel = UILabel()
messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = NSLocalizedString("modal_call_explanation", comment: "")
messageLabel.text = message
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center
// Enable button
let enableButton = UIButton()
enableButton.set(.height, to: Values.mediumButtonHeight)
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
enableButton.backgroundColor = Colors.buttonBackground
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
// Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
}
// MARK: Interaction
@objc private func enable() {
SSKPreferences.areCallsEnabled = true
presentingViewController?.dismiss(animated: true, completion: nil)
onCallEnabled()
}
}

View File

@ -0,0 +1,79 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
@objc
final class CallPermissionRequestModal : Modal {
// MARK: Lifecycle
@objc
init() {
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(onCallEnabled:) instead.")
}
override func populateContentView() {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_call_permission_request_title", comment: "")
titleLabel.textAlignment = .center
// Message
let messageLabel = UILabel()
messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = NSLocalizedString("modal_call_permission_request_explanation", comment: "")
messageLabel.text = message
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center
// Enable button
let goToSettingsButton = UIButton()
goToSettingsButton.set(.height, to: Values.mediumButtonHeight)
goToSettingsButton.layer.cornerRadius = Modal.buttonCornerRadius
goToSettingsButton.backgroundColor = Colors.buttonBackground
goToSettingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
goToSettingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
goToSettingsButton.setTitle(NSLocalizedString("vc_settings_title", comment: ""), for: UIControl.State.normal)
goToSettingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing
// Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, goToSettingsButton ])
buttonStackView.axis = .horizontal
buttonStackView.distribution = .fillEqually
// Main stack view
let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = spacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
}
// MARK: Interaction
@objc func goToSettings(_ sender: Any) {
dismiss(animated: true, completion: {
if let vc = CurrentAppContext().frontmostViewController() {
let privacySettingsVC = PrivacySettingsTableViewController()
privacySettingsVC.shouldShowCloseButton = true
let nav = OWSNavigationController(rootViewController: privacySettingsVC)
nav.modalPresentationStyle = .fullScreen
vc.present(nav, animated: true, completion: nil)
}
})
}
}

View File

@ -23,6 +23,14 @@ final class ConversationTitleView : UIView {
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
result.axis = .vertical
result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true
return result
}()
// MARK: Lifecycle
init(thread: TSThread) {
@ -40,11 +48,6 @@ final class ConversationTitleView : UIView {
}
private func initialize() {
let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
stackView.axis = .vertical
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
addSubview(stackView)
stackView.pin(to: self)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
@ -67,6 +70,11 @@ final class ConversationTitleView : UIView {
subtitleLabel.attributedText = subtitle
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
// Update title left margin
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread() && !thread.isMessageRequest()
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
}
// MARK: General

View File

@ -23,7 +23,7 @@ final class DownloadAttachmentModal : Modal {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
titleLabel.textAlignment = .center
// Message
@ -41,7 +41,6 @@ final class DownloadAttachmentModal : Modal {
let downloadButton = UIButton()
downloadButton.set(.height, to: Values.mediumButtonHeight)
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
downloadButton.backgroundColor = Colors.buttonBackground
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
@ -51,15 +50,20 @@ final class DownloadAttachmentModal : Modal {
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
mainStackView.spacing = spacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
}
// MARK: Interaction

View File

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

View File

@ -20,7 +20,7 @@ final class LinkPreviewModal : Modal {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
titleLabel.textAlignment = .center
// Message
@ -46,15 +46,20 @@ final class LinkPreviewModal : Modal {
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
mainStackView.spacing = spacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
}
// MARK: Interaction

View File

@ -14,6 +14,7 @@ final class MessagesTableView : UITableView {
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
separatorStyle = .none
backgroundColor = .clear
showsVerticalScrollIndicator = false

View File

@ -22,7 +22,7 @@ final class PermissionMissingModal : Modal {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = "Session"
titleLabel.textAlignment = .center
// Message
@ -50,15 +50,20 @@ final class PermissionMissingModal : Modal {
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
mainStackView.spacing = spacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
}
// MARK: Interaction

View File

@ -5,7 +5,7 @@ final class SendSeedModal : Modal {
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.largeFontSize)
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.text = NSLocalizedString("modal_send_seed_title", comment: "")
result.textAlignment = .center
return result
@ -44,19 +44,27 @@ final class SendSeedModal : Modal {
return result
}()
private lazy var mainStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, buttonStackView ])
private lazy var contentStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ])
result.axis = .vertical
result.spacing = Values.largeSpacing
return result
}()
private lazy var mainStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
result.axis = .vertical
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
return result
}()
// MARK: Lifecycle
override func populateContentView() {
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing)
}
// MARK: Interaction

View File

@ -20,7 +20,7 @@ final class URLModal : Modal {
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
titleLabel.textAlignment = .center
// Message
@ -48,15 +48,20 @@ final class URLModal : Modal {
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
mainStackView.spacing = spacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
}
// MARK: Interaction

View File

@ -73,7 +73,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
tabBar.pin(.leading, to: .leading, of: view)
let tabBarInset: CGFloat
if #available(iOS 13, *) {
tabBarInset = navigationBar.height()
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
} else {
tabBarInset = 0
}
@ -177,6 +177,7 @@ private final class EnterPublicKeyVC : UIViewController {
weak var NewDMVC: NewDMVC!
private var isKeyboardShowing = false
private var bottomConstraint: NSLayoutConstraint!
private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0
// MARK: Components
private lazy var publicKeyTextView: TextView = {
@ -212,8 +213,12 @@ private final class EnterPublicKeyVC : UIViewController {
private lazy var buttonContainer: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
result.spacing = Values.mediumSpacing
result.spacing = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing
result.distribution = .fillEqually
if (UIDevice.current.isIPad) {
result.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin)
result.isLayoutMarginsRelativeArrangement = true
}
return result
}()
@ -221,6 +226,8 @@ private final class EnterPublicKeyVC : UIViewController {
override func viewDidLoad() {
// Remove background color
view.backgroundColor = .clear
// User session id container
let userPublicKeyContainer = UIView(wrapping: userPublicKeyLabel, withInsets: .zero, shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth)
// Explanation label
let explanationLabel = UILabel()
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
@ -240,14 +247,9 @@ private final class EnterPublicKeyVC : UIViewController {
let nextButton = Button(style: .prominentOutline, size: .large)
nextButton.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal)
nextButton.addTarget(self, action: #selector(startNewDMIfPossible), for: UIControl.Event.touchUpInside)
let nextButtonContainer = UIView()
nextButtonContainer.addSubview(nextButton)
nextButton.pin(.leading, to: .leading, of: nextButtonContainer, withInset: 80)
nextButton.pin(.top, to: .top, of: nextButtonContainer)
nextButtonContainer.pin(.trailing, to: .trailing, of: nextButton, withInset: 80)
nextButtonContainer.pin(.bottom, to: .bottom, of: nextButton)
let nextButtonContainer = UIView(wrapping: nextButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth)
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyLabel, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyContainer, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
mainStackView.axis = .vertical
mainStackView.alignment = .fill
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing)
@ -256,7 +258,7 @@ private final class EnterPublicKeyVC : UIViewController {
mainStackView.pin(.leading, to: .leading, of: view)
mainStackView.pin(.top, to: .top, of: view)
view.pin(.trailing, to: .trailing, of: mainStackView)
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView)
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView, withInset: bottomMargin)
// Width constraint
view.set(.width, to: UIScreen.main.bounds.width)
// Dismiss keyboard on tap
@ -297,7 +299,7 @@ private final class EnterPublicKeyVC : UIViewController {
guard !isKeyboardShowing else { return }
isKeyboardShowing = true
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
bottomConstraint.constant = newHeight
bottomConstraint.constant = newHeight + bottomMargin
UIView.animate(withDuration: 0.25) {
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
$0.alpha = 0
@ -310,7 +312,7 @@ private final class EnterPublicKeyVC : UIViewController {
@objc private func handleKeyboardWillHideNotification(_ notification: Notification) {
guard isKeyboardShowing else { return }
isKeyboardShowing = false
bottomConstraint.constant = 0
bottomConstraint.constant = bottomMargin
UIView.animate(withDuration: 0.25) {
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
$0.alpha = 1
@ -332,6 +334,12 @@ private final class EnterPublicKeyVC : UIViewController {
@objc private func sharePublicKey() {
let shareVC = UIActivityViewController(activityItems: [ getUserHexEncodedPublicKey() ], applicationActivities: nil)
if UIDevice.current.isIPad {
shareVC.excludedActivityTypes = []
shareVC.popoverPresentationController?.permittedArrowDirections = []
shareVC.popoverPresentationController?.sourceView = self.view
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
}
NewDMVC.navigationController!.present(shareVC, animated: true, completion: nil)
}

View File

@ -1,5 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
@objc
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
@ -94,8 +96,23 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
searchBarContainer.set(.height, to: 44)
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
searchBarContainer.addSubview(searchBar)
searchBar.autoPinEdgesToSuperviewMargins()
navigationItem.titleView = searchBarContainer
// On iPad, the cancel button won't show
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
if UIDevice.current.isIPad {
let ipadCancelButton = UIButton()
ipadCancelButton.setTitle("Cancel", for: .normal)
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside)
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
searchBarContainer.addSubview(ipadCancelButton)
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
ipadCancelButton.autoVCenterInSuperview()
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
} else {
searchBar.autoPinEdgesToSuperviewMargins()
}
}
private func reloadTableData() {
@ -150,6 +167,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
Storage.shared.clearRecentSearchResults()
}
@objc func cancel(_ sender: Any) {
self.navigationController?.popViewController(animated: true)
}
}

View File

@ -83,7 +83,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_home_empty_state_button_title", comment: ""), for: UIControl.State.normal)
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
createNewPrivateChatButton.set(.width, to: 196)
createNewPrivateChatButton.set(.width, to: Values.iPadButtonWidth)
let result = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
result.axis = .vertical
result.spacing = Values.mediumSpacing
@ -154,7 +154,6 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name(kNSNotificationName_LocalProfileDidChange), object: nil)
notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil)
// Threads (part 2)
threads = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup, TSInboxGroup ], view: TSThreadDatabaseViewExtensionName) // The extension should be registered at this point
threads.setIsReversed(true, forGroup: TSInboxGroup)
@ -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)
}

View File

@ -153,8 +153,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -Values.largeSpacing
),
// Note: The '182' is to match the 'Next' button on the New DM page (which doesn't have a fixed width)
clearAllButton.widthAnchor.constraint(equalToConstant: 182),
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
clearAllButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
])
}

View File

@ -379,7 +379,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
}
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
collectionView.reloadData()
reloadDataAndRestoreSelection()
}
func clearCollectionViewSelection() {
@ -551,12 +551,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
cell.configure(item: assetItem)
let isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
if isSelected {
cell.isSelected = isSelected
} else {
cell.isSelected = isSelected
}
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
return cell
}

View File

@ -3,7 +3,6 @@
//
#import "MediaDetailViewController.h"
#import "AttachmentSharing.h"
#import "ConversationViewItem.h"
#import "Session-Swift.h"
#import "TSAttachmentStream.h"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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