session-ios/Session/Calls/UserInterface/Group/GroupCallViewController.swift

762 lines
27 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalRingRTC
// TODO: Eventually add 1:1 call support to this view
// and replace CallViewController
class GroupCallViewController: UIViewController {
private let thread: TSGroupThread?
private let call: SignalCall
private var groupCall: GroupCall { call.groupCall }
private lazy var callControls = CallControls(call: call, delegate: self)
private lazy var callHeader = CallHeader(call: call, delegate: self)
private lazy var notificationView = GroupCallNotificationView(call: call)
private lazy var videoGrid = GroupCallVideoGrid(call: call)
private lazy var videoOverflow = GroupCallVideoOverflow(call: call, delegate: self)
private let localMemberView = GroupCallLocalMemberView()
private let speakerView = GroupCallRemoteMemberView(mode: .speaker)
private var didUserEverSwipeToSpeakerView = true
private var didUserEverSwipeToScreenShare = true
private let swipeToastView = GroupCallSwipeToastView()
private var speakerPage = UIView()
private let scrollView = UIScrollView()
private var isCallMinimized = false {
didSet { speakerView.isCallMinimized = isCallMinimized }
}
private var isAutoScrollingToScreenShare = false
private var isAnyRemoteDeviceScreenSharing = false {
didSet {
guard oldValue != isAnyRemoteDeviceScreenSharing else { return }
// Scroll to speaker view when presenting begins.
if isAnyRemoteDeviceScreenSharing {
isAutoScrollingToScreenShare = true
scrollView.setContentOffset(CGPoint(x: 0, y: speakerPage.frame.origin.y), animated: true)
}
}
}
lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView))
lazy var videoOverflowTopConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .top)
lazy var videoOverflowTrailingConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .trailing)
var shouldRemoteVideoControlsBeHidden = false {
didSet { updateCallUI() }
}
var hasUnresolvedSafetyNumberMismatch = false
private static let keyValueStore = SDSKeyValueStore(collection: "GroupCallViewController")
private static let didUserSwipeToSpeakerViewKey = "didUserSwipeToSpeakerView"
private static let didUserSwipeToScreenShareKey = "didUserSwipeToScreenShare"
init(call: SignalCall) {
// TODO: Eventually unify UI for group and individual calls
owsAssertDebug(call.isGroupCall)
self.call = call
self.thread = call.thread as? TSGroupThread
super.init(nibName: nil, bundle: nil)
call.addObserverAndSyncState(observer: self)
videoGrid.memberViewDelegate = self
videoOverflow.memberViewDelegate = self
speakerView.delegate = self
localMemberView.delegate = self
SDSDatabaseStorage.shared.asyncRead { readTx in
self.didUserEverSwipeToSpeakerView = Self.keyValueStore.getBool(
Self.didUserSwipeToSpeakerViewKey,
defaultValue: false,
transaction: readTx
)
self.didUserEverSwipeToScreenShare = Self.keyValueStore.getBool(
Self.didUserSwipeToScreenShareKey,
defaultValue: false,
transaction: readTx
)
} completion: {
self.updateSwipeToastView()
}
}
@discardableResult
@objc(presentLobbyForThread:)
class func presentLobby(thread: TSGroupThread) -> Bool {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return false
}
frontmostViewController.ows_ask(forMicrophonePermissions: { granted in
guard granted == true else {
Logger.warn("aborting due to missing microphone permissions.")
frontmostViewController.ows_showNoMicrophonePermissionActionSheet()
return
}
frontmostViewController.ows_ask(forCameraPermissions: { granted in
guard granted else {
Logger.warn("aborting due to missing camera permissions.")
return
}
guard let groupCall = Self.callService.buildAndConnectGroupCallIfPossible(
thread: thread
) else {
return owsFailDebug("Failed to build group call")
}
let vc = GroupCallViewController(call: groupCall)
vc.modalTransitionStyle = .crossDissolve
OWSWindowManager.shared().startCall(vc)
})
})
return true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = UIView()
view.clipsToBounds = true
view.backgroundColor = .ows_black
scrollView.delegate = self
view.addSubview(scrollView)
scrollView.isPagingEnabled = true
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.alwaysBounceVertical = false
scrollView.autoPinEdgesToSuperviewEdges()
view.addSubview(callHeader)
callHeader.autoPinWidthToSuperview()
callHeader.autoPinEdge(toSuperviewEdge: .top)
view.addSubview(notificationView)
notificationView.autoPinEdgesToSuperviewEdges()
view.addSubview(callControls)
callControls.autoPinWidthToSuperview()
callControls.autoPinEdge(toSuperviewEdge: .bottom)
view.addSubview(videoOverflow)
videoOverflow.autoPinEdge(toSuperviewEdge: .leading)
scrollView.addSubview(videoGrid)
scrollView.addSubview(speakerPage)
scrollView.addSubview(swipeToastView)
swipeToastView.autoPinEdge(.bottom, to: .bottom, of: videoGrid, withOffset: -22)
swipeToastView.autoHCenterInSuperview()
swipeToastView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
swipeToastView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual)
view.addGestureRecognizer(tapGesture)
updateCallUI()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
let wasOnSpeakerPage = scrollView.contentOffset.y >= view.height()
coordinator.animate(alongsideTransition: { _ in
self.updateCallUI(size: size)
self.videoGrid.reloadData()
self.videoOverflow.reloadData()
self.scrollView.contentOffset = wasOnSpeakerPage ? CGPoint(x: 0, y: size.height) : .zero
}, completion: nil)
}
private var hasAppeared = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard !hasAppeared else { return }
hasAppeared = true
guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else {
return owsFailDebug("failed to snapshot rootViewController")
}
view.superview?.insertSubview(splitViewSnapshot, belowSubview: view)
splitViewSnapshot.autoPinEdgesToSuperviewEdges()
view.transform = .scale(1.5)
view.alpha = 0
UIView.animate(withDuration: 0.2, animations: {
self.view.alpha = 1
self.view.transform = .identity
}) { _ in
splitViewSnapshot.removeFromSuperview()
}
}
private var hasOverflowMembers: Bool { videoGrid.maxItems < groupCall.remoteDeviceStates.count }
private func updateScrollViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) {
view.layoutIfNeeded()
let size = size ?? view.frame.size
if groupCall.remoteDeviceStates.count < 2 || groupCall.localDeviceState.joinState != .joined {
videoGrid.frame = .zero
videoGrid.isHidden = true
speakerPage.frame = CGRect(
x: 0,
y: 0,
width: size.width,
height: size.height
)
scrollView.contentSize = size
scrollView.contentOffset = .zero
scrollView.isScrollEnabled = false
} else {
let wasVideoGridHidden = videoGrid.isHidden
scrollView.isScrollEnabled = true
videoGrid.isHidden = false
let height = size.height - view.safeAreaInsets.top - (controlsAreHidden ? 16 : callControls.height()) - (hasOverflowMembers ? videoOverflow.height() + 32 : 0)
videoGrid.frame = CGRect(
x: 0,
y: view.safeAreaInsets.top,
width: size.width,
height: height
)
speakerPage.frame = CGRect(
x: 0,
y: size.height,
width: size.width,
height: size.height
)
scrollView.contentSize = CGSize(width: size.width, height: size.height * 2)
if wasVideoGridHidden {
scrollView.contentOffset = .zero
}
}
}
func updateVideoOverflowTrailingConstraint() {
var trailingConstraintConstant = -(GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.pipSize.aspectRatio + 4)
if view.width() + trailingConstraintConstant > videoOverflow.contentSize.width {
trailingConstraintConstant += 16
}
videoOverflowTrailingConstraint.constant = trailingConstraintConstant
view.layoutIfNeeded()
}
private func updateMemberViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) {
view.layoutIfNeeded()
let size = size ?? view.frame.size
let yMax = (controlsAreHidden ? size.height - 16 : callControls.frame.minY) - 16
videoOverflowTopConstraint.constant = yMax - videoOverflow.height()
updateVideoOverflowTrailingConstraint()
localMemberView.removeFromSuperview()
speakerView.removeFromSuperview()
switch groupCall.localDeviceState.joinState {
case .joined:
if groupCall.remoteDeviceStates.count > 0 {
speakerPage.addSubview(speakerView)
speakerView.autoPinEdgesToSuperviewEdges()
view.addSubview(localMemberView)
if groupCall.remoteDeviceStates.count > 1 {
let pipWidth = GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.pipSize.aspectRatio
let pipHeight = GroupCallVideoOverflow.itemHeight
localMemberView.frame = CGRect(
x: size.width - pipWidth - 16,
y: videoOverflow.frame.origin.y,
width: pipWidth,
height: pipHeight
)
} else {
let pipWidth = ReturnToCallViewController.pipSize.width
let pipHeight = ReturnToCallViewController.pipSize.height
localMemberView.frame = CGRect(
x: size.width - pipWidth - 16,
y: yMax - pipHeight,
width: pipWidth,
height: pipHeight
)
}
} else {
speakerPage.addSubview(localMemberView)
localMemberView.frame = CGRect(origin: .zero, size: size)
}
case .notJoined, .joining:
speakerPage.addSubview(localMemberView)
localMemberView.frame = CGRect(origin: .zero, size: size)
}
}
func updateSwipeToastView() {
let isSpeakerViewAvailable = groupCall.remoteDeviceStates.count >= 2 && groupCall.localDeviceState.joinState == .joined
guard isSpeakerViewAvailable else {
swipeToastView.isHidden = true
return
}
if isAnyRemoteDeviceScreenSharing {
if didUserEverSwipeToScreenShare {
swipeToastView.isHidden = true
return
}
} else if didUserEverSwipeToSpeakerView {
swipeToastView.isHidden = true
return
}
swipeToastView.alpha = 1.0 - (scrollView.contentOffset.y / view.height())
swipeToastView.text = isAnyRemoteDeviceScreenSharing
? NSLocalizedString(
"GROUP_CALL_SCREEN_SHARE_TOAST",
comment: "Toast view text informing user about swiping to screen share"
)
: NSLocalizedString(
"GROUP_CALL_SPEAKER_VIEW_TOAST",
comment: "Toast view text informing user about swiping to speaker view"
)
if scrollView.contentOffset.y >= view.height() {
swipeToastView.isHidden = true
if isAnyRemoteDeviceScreenSharing {
if !isAutoScrollingToScreenShare {
didUserEverSwipeToScreenShare = true
// TODO: Implement
/*
SDSDatabaseStorage.shared.asyncWrite { writeTx in
Self.keyValueStore.setBool(true, key: Self.didUserSwipeToScreenShareKey, transaction: writeTx)
}
*/
}
} else {
didUserEverSwipeToSpeakerView = true
// TODO: Implement
/*
SDSDatabaseStorage.shared.asyncWrite { writeTx in
Self.keyValueStore.setBool(true, key: Self.didUserSwipeToSpeakerViewKey, transaction: writeTx)
}
*/
}
} else if swipeToastView.isHidden {
swipeToastView.alpha = 0
swipeToastView.isHidden = false
UIView.animate(withDuration: 0.2, delay: 3.0, options: []) {
self.swipeToastView.alpha = 1
}
}
}
func updateCallUI(size: CGSize? = nil) {
let localDevice = groupCall.localDeviceState
localMemberView.configure(
call: call,
isFullScreen: localDevice.joinState != .joined || groupCall.remoteDeviceStates.isEmpty
)
if let speakerState = groupCall.remoteDeviceStates.sortedBySpeakerTime.first {
speakerView.configure(
call: call,
device: speakerState
)
} else {
speakerView.clearConfiguration()
}
// Setting the speakerphone before we join the call will fail,
// but we can re-apply the setting here in case it did not work.
if groupCall.isOutgoingVideoMuted && !callService.audioService.hasExternalInputs {
callService.audioService.requestSpeakerphone(isEnabled: callControls.audioSourceButton.isSelected)
}
guard !isCallMinimized else { return }
let hideRemoteControls = shouldRemoteVideoControlsBeHidden && !groupCall.remoteDeviceStates.isEmpty
let remoteControlsAreHidden = callControls.isHidden && callHeader.isHidden
if hideRemoteControls != remoteControlsAreHidden {
callControls.isHidden = false
callHeader.isHidden = false
UIView.animate(withDuration: 0.15, animations: {
self.callControls.alpha = hideRemoteControls ? 0 : 1
self.callHeader.alpha = hideRemoteControls ? 0 : 1
self.updateMemberViewFrames(size: size, controlsAreHidden: hideRemoteControls)
self.updateScrollViewFrames(size: size, controlsAreHidden: hideRemoteControls)
self.view.layoutIfNeeded()
}) { _ in
self.callControls.isHidden = hideRemoteControls
self.callHeader.isHidden = hideRemoteControls
}
} else {
updateMemberViewFrames(size: size, controlsAreHidden: hideRemoteControls)
updateScrollViewFrames(size: size, controlsAreHidden: hideRemoteControls)
}
scheduleControlTimeoutIfNecessary()
updateSwipeToastView()
}
func dismissCall() {
callService.terminate(call: call)
guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else {
OWSWindowManager.shared().endCall(self)
return owsFailDebug("failed to snapshot rootViewController")
}
view.superview?.insertSubview(splitViewSnapshot, belowSubview: view)
splitViewSnapshot.autoPinEdgesToSuperviewEdges()
UIView.animate(withDuration: 0.2, animations: {
self.view.alpha = 0
}) { _ in
splitViewSnapshot.removeFromSuperview()
OWSWindowManager.shared().endCall(self)
}
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override var prefersHomeIndicatorAutoHidden: Bool {
return true
}
// MARK: - Video control timeout
@objc func didTouchRootView(sender: UIGestureRecognizer) {
shouldRemoteVideoControlsBeHidden = !shouldRemoteVideoControlsBeHidden
}
private var controlTimeoutTimer: Timer?
private func scheduleControlTimeoutIfNecessary() {
if groupCall.remoteDeviceStates.isEmpty || shouldRemoteVideoControlsBeHidden {
controlTimeoutTimer?.invalidate()
controlTimeoutTimer = nil
}
guard controlTimeoutTimer == nil else { return }
controlTimeoutTimer = .weakScheduledTimer(
withTimeInterval: 5,
target: self,
selector: #selector(timeoutControls),
userInfo: nil,
repeats: false
)
}
@objc
private func timeoutControls() {
controlTimeoutTimer?.invalidate()
controlTimeoutTimer = nil
guard !isCallMinimized && !groupCall.remoteDeviceStates.isEmpty && !shouldRemoteVideoControlsBeHidden else { return }
shouldRemoteVideoControlsBeHidden = true
}
}
extension GroupCallViewController: CallViewControllerWindowReference {
var localVideoViewReference: UIView { localMemberView }
var remoteVideoViewReference: UIView { speakerView }
var remoteVideoAddress: String {
guard let firstMember = groupCall.remoteDeviceStates.sortedByAddedTime.first else {
return getUserHexEncodedPublicKey()
}
return firstMember.address
}
@objc
public func returnFromPip(pipWindow: UIWindow) {
// The call "pip" uses our remote and local video views since only
// one `AVCaptureVideoPreviewLayer` per capture session is supported.
// We need to re-add them when we return to this view.
guard speakerView.superview != speakerPage && localMemberView.superview != view else {
return owsFailDebug("unexpectedly returned to call while we own the video views")
}
guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else {
return owsFailDebug("failed to snapshot rootViewController")
}
guard let pipSnapshot = pipWindow.snapshotView(afterScreenUpdates: false) else {
return owsFailDebug("failed to snapshot pip")
}
isCallMinimized = false
shouldRemoteVideoControlsBeHidden = false
animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot)
}
private func animateReturnFromPip(pipSnapshot: UIView, pipFrame: CGRect, splitViewSnapshot: UIView) {
guard let window = view.window else { return owsFailDebug("missing window") }
view.superview?.insertSubview(splitViewSnapshot, belowSubview: view)
splitViewSnapshot.autoPinEdgesToSuperviewEdges()
let originalContentOffset = scrollView.contentOffset
view.frame = pipFrame
view.addSubview(pipSnapshot)
pipSnapshot.autoPinEdgesToSuperviewEdges()
view.layoutIfNeeded()
UIView.animate(withDuration: 0.2, animations: {
pipSnapshot.alpha = 0
self.view.frame = window.frame
self.updateCallUI()
self.scrollView.contentOffset = originalContentOffset
self.view.layoutIfNeeded()
}) { _ in
splitViewSnapshot.removeFromSuperview()
pipSnapshot.removeFromSuperview()
}
}
}
extension GroupCallViewController: CallObserver {
func groupCallLocalDeviceStateChanged(_ call: SignalCall) {
AssertIsOnMainThread()
owsAssertDebug(call.isGroupCall)
updateCallUI()
}
func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) {
AssertIsOnMainThread()
owsAssertDebug(call.isGroupCall)
isAnyRemoteDeviceScreenSharing = call.groupCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil
updateCallUI()
}
func groupCallPeekChanged(_ call: SignalCall) {
AssertIsOnMainThread()
owsAssertDebug(call.isGroupCall)
updateCallUI()
}
func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) {
// TODO: Implement
/*
AssertIsOnMainThread()
owsAssertDebug(call.isGroupCall)
defer { updateCallUI() }
guard reason != .deviceExplicitlyDisconnected else { return }
let title: String
if reason == .hasMaxDevices {
if let maxDevices = groupCall.maxDevices {
let formatString = NSLocalizedString(
"GROUP_CALL_HAS_MAX_DEVICES_FORMAT",
comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}."
)
title = String(format: formatString, maxDevices)
} else {
title = NSLocalizedString(
"GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT",
comment: "An error displayed to the user when the group call ends because it has exceeded the max devices."
)
}
} else {
owsFailDebug("Group call ended with reason \(reason)")
title = NSLocalizedString(
"GROUP_CALL_UNEXPECTEDLY_ENDED",
comment: "An error displayed to the user when the group call unexpectedly ends."
)
}
let actionSheet = ActionSheetController(title: title)
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.okButton,
style: .default,
handler: { [weak self] _ in
guard reason == .hasMaxDevices else { return }
self?.dismissCall()
}
))
presentActionSheet(actionSheet)
*/
}
func callMessageSendFailedUntrustedIdentity(_ call: SignalCall) {
// TODO: Do something
}
}
extension GroupCallViewController: CallControlsDelegate {
func didPressHangup(sender: UIButton) {
dismissCall()
}
func didPressAudioSource(sender: UIButton) {
if callService.audioService.hasExternalInputs {
callService.audioService.presentRoutePicker()
} else {
sender.isSelected = !sender.isSelected
callService.audioService.requestSpeakerphone(isEnabled: sender.isSelected)
}
}
func didPressMute(sender: UIButton) {
sender.isSelected = !sender.isSelected
callService.updateIsLocalAudioMuted(isLocalAudioMuted: sender.isSelected)
}
func didPressVideo(sender: UIButton) {
sender.isSelected = !sender.isSelected
callService.updateIsLocalVideoMuted(isLocalVideoMuted: !sender.isSelected)
// When turning off video, default speakerphone to on.
if !sender.isSelected && !callService.audioService.hasExternalInputs {
callControls.audioSourceButton.isSelected = true
callService.audioService.requestSpeakerphone(isEnabled: true)
}
}
func didPressFlipCamera(sender: UIButton) {
sender.isSelected = !sender.isSelected
callService.updateCameraSource(call: call, isUsingFrontCamera: !sender.isSelected)
}
func didPressCancel(sender: UIButton) {
dismissCall()
}
func didPressJoin(sender: UIButton) {
self.callService.joinGroupCallIfNecessary(self.call)
}
}
extension GroupCallViewController: CallHeaderDelegate {
func didTapBackButton() {
if groupCall.localDeviceState.joinState == .joined {
isCallMinimized = true
OWSWindowManager.shared().leaveCallView()
} else {
dismissCall()
}
}
func didTapMembersButton() {
// TODO: Implement
/*
let sheet = GroupCallMemberSheet(call: call)
present(sheet, animated: true)
*/
}
}
extension GroupCallViewController: GroupCallVideoOverflowDelegate {
var firstOverflowMemberIndex: Int {
if scrollView.contentOffset.y >= view.height() {
return 1
} else {
return videoGrid.maxItems
}
}
}
extension GroupCallViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// If we changed pages, update the overflow view.
if scrollView.contentOffset.y == 0 || scrollView.contentOffset.y == view.height() {
videoOverflow.reloadData()
updateCallUI()
}
if isAutoScrollingToScreenShare {
isAutoScrollingToScreenShare = scrollView.contentOffset.y != speakerView.frame.origin.y
}
updateSwipeToastView()
}
}
extension GroupCallViewController: GroupCallMemberViewDelegate {
func memberView(_ view: GroupCallMemberView, userRequestedInfoAboutError error: GroupCallMemberView.ErrorState) {
// TODO: Implement
/*
let title: String
let message: String
switch error {
case let .blocked(address):
message = NSLocalizedString(
"GROUP_CALL_BLOCKED_ALERT_MESSAGE",
comment: "Message body for alert explaining that a group call participant is blocked")
let titleFormat = NSLocalizedString(
"GROUP_CALL_BLOCKED_ALERT_TITLE_FORMAT",
comment: "Title for alert explaining that a group call participant is blocked. Embeds {{ user's name }}")
let displayName = Storage.shared.getContact(with: address)?.displayName(for: .regular) ?? address
title = String(format: titleFormat, displayName)
case let .noMediaKeys(address):
message = NSLocalizedString(
"GROUP_CALL_NO_KEYS_ALERT_MESSAGE",
comment: "Message body for alert explaining that a group call participant cannot be displayed because of missing keys")
let titleFormat = NSLocalizedString(
"GROUP_CALL_NO_KEYS_ALERT_TITLE_FORMAT",
comment: "Title for alert explaining that a group call participant cannot be displayed because of missing keys. Embeds {{ user's name }}")
let displayName = Storage.shared.getContact(with: address)?.displayName(for: .regular) ?? address
title = String(format: titleFormat, displayName)
}
let actionSheet = ActionSheetController(title: title, message: message, theme: .translucentDark)
actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton))
presentActionSheet(actionSheet)
*/
}
}