diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7299c8883..307b1138d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -137,6 +137,9 @@ 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; 76EB054018170B33006006FC /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 76EB03C318170B33006006FC /* AppDelegate.m */; }; 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; }; + 7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E3271FC59C00848B49 /* CallModal.swift */; }; + 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */; }; + 7B1581E827210ECC00848B49 /* RenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E727210ECC00848B49 /* RenderView.swift */; }; 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; }; 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; 7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB188270430D20079FF93 /* CallMessageView.swift */; }; @@ -1116,6 +1119,9 @@ 76EB03C218170B33006006FC /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 76EB03C318170B33006006FC /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = ""; }; + 7B1581E3271FC59C00848B49 /* CallModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModal.swift; sourceTree = ""; }; + 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = ""; }; + 7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = ""; }; 7B2DB2AD26F1B0FF0035B509 /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Localizable.strings; sourceTree = ""; }; 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = ""; }; 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = ""; }; @@ -2053,6 +2059,7 @@ children = ( 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */, 7B7CB18F270FB2150079FF93 /* MiniCallView.swift */, + 7B1581E727210ECC00848B49 /* RenderView.swift */, ); path = "Views & Modals"; sourceTree = ""; @@ -2169,6 +2176,7 @@ B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */, C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, B848A4C4269EAAA200617031 /* UserDetailsSheet.swift */, + 7B1581E3271FC59C00848B49 /* CallModal.swift */, ); path = "Views & Modals"; sourceTree = ""; @@ -2350,6 +2358,7 @@ isa = PBXGroup; children = ( B877E24126CA12910007970A /* CallVC.swift */, + 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */, B877E24526CA13BA0007970A /* CallVC+Camera.swift */, B8B558F026C4BB0600693325 /* CameraManager.swift */, 7B7CB18C270D06350079FF93 /* Views & Modals */, @@ -4837,6 +4846,7 @@ 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, + 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, @@ -4917,6 +4927,7 @@ B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */, B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, + 7B1581E827210ECC00848B49 /* RenderView.swift in Sources */, 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, @@ -4926,6 +4937,7 @@ 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */, 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, + 7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 482344033..6b57e9ec1 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -4,7 +4,7 @@ import SessionMessagingKit import SessionUtilitiesKit import UIKit -final class CallVC : UIViewController, WebRTCSessionDelegate { +final class CallVC : UIViewController, WebRTCSessionDelegate, VideoPreviewDelegate { let sessionID: String let uuid: String let mode: Mode @@ -225,7 +225,7 @@ final class CallVC : UIViewController, WebRTCSessionDelegate { view.addSubview(fadeView) fadeView.translatesAutoresizingMaskIntoConstraints = false fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view) - // Close button + // Minimize button view.addSubview(minimizeButton) minimizeButton.translatesAutoresizingMaskIntoConstraints = false minimizeButton.pin(.left, to: .left, of: view) @@ -354,15 +354,22 @@ final class CallVC : UIViewController, WebRTCSessionDelegate { cameraManager.stop() videoButton.alpha = 0.5 switchCameraButton.isEnabled = false + isVideoEnabled = false } else { - webRTCSession.turnOnVideo() - localVideoView.isHidden = false - cameraManager.prepare() - cameraManager.start() - videoButton.alpha = 1.0 - switchCameraButton.isEnabled = true + let previewVC = VideoPreviewVC() + previewVC.delegate = self + present(previewVC, animated: true, completion: nil) } - isVideoEnabled = !isVideoEnabled + } + + func cameraDidConfirmTurningOn() { + webRTCSession.turnOnVideo() + localVideoView.isHidden = false + cameraManager.prepare() + cameraManager.start() + videoButton.alpha = 1.0 + switchCameraButton.isEnabled = true + isVideoEnabled = true } @objc private func switchCamera() { diff --git a/Session/Calls/VideoPreviewVC.swift b/Session/Calls/VideoPreviewVC.swift new file mode 100644 index 000000000..df6e98377 --- /dev/null +++ b/Session/Calls/VideoPreviewVC.swift @@ -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) + } +} diff --git a/Session/Calls/Views & Modals/RenderView.swift b/Session/Calls/Views & Modals/RenderView.swift new file mode 100644 index 000000000..da2bd4c56 --- /dev/null +++ b/Session/Calls/Views & Modals/RenderView.swift @@ -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) + } + +} diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index faad7c3be..8cfee86bb 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -27,7 +27,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } // MARK: Call - @objc func startCall(_ sender: Any) { + @objc func startCall(_ sender: Any?) { guard let contactSessionID = (thread as? TSContactThread)?.contactSessionID() else { return } let callVC = CallVC(for: contactSessionID, uuid: UUID().uuidString, mode: .offer) callVC.conversationVC = self @@ -38,6 +38,15 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc present(callVC, animated: true, completion: nil) } + internal func showCallModal() { + let callModal = CallModal() { [weak self] in + self?.startCall(nil) + } + callModal.modalPresentationStyle = .overFullScreen + callModal.modalTransitionStyle = .crossDissolve + present(callModal, animated: true, completion: nil) + } + internal func showCallVCIfNeeded() { guard let contactSessionID = (thread as? TSContactThread)?.contactSessionID(), let incomingCallBanner = IncomingCallBanner.current, incomingCallBanner.sessionID == contactSessionID diff --git a/Session/Conversations/Views & Modals/CallModal.swift b/Session/Conversations/Views & Modals/CallModal.swift new file mode 100644 index 000000000..0620646e7 --- /dev/null +++ b/Session/Conversations/Views & Modals/CallModal.swift @@ -0,0 +1,65 @@ + +final class CallModal : Modal { + private let onCallEnabled: () -> Void + + // MARK: Lifecycle + init(onCallEnabled: @escaping () -> Void) { + self.onCallEnabled = onCallEnabled + super.init(nibName: nil, bundle: nil) + } + + 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() { + presentingViewController?.dismiss(animated: true, completion: nil) + onCallEnabled() + } +} diff --git a/Session/Meta/Images.xcassets/Session/Check.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Check.imageset/Contents.json new file mode 100644 index 000000000..37accc538 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/Check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "check.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Session/Check.imageset/check.pdf b/Session/Meta/Images.xcassets/Session/Check.imageset/check.pdf new file mode 100644 index 000000000..86c5f39ce Binary files /dev/null and b/Session/Meta/Images.xcassets/Session/Check.imageset/check.pdf differ diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 6861f3c03..6316ecd13 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -538,6 +538,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 and 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";